Tino Developers
Xem .md

Nhận webhook TinoPay

Mỗi giao dịch vào (hoặc ra) tài khoản ngân hàng đã liên kết, TinoPay gửi một request POST tới URL webhook của bạn; body JSON là dữ liệu giao dịch, header mang token của webhook để bạn xác minh. Đây là cách nên dùng cho luồng cần xác nhận thanh toán tức thời (cổng thanh toán, nạp tiền tự động, xác nhận đơn hàng), thay vì gọi API hỏi lặp.

Cập nhật: 2026-10-25. Nguồn: tài liệu webhook của TinoPay và tài liệu TinoPay API v1.

Luồng hoạt động

  1. Khách chuyển khoản vào tài khoản ngân hàng đã kết nối với TinoPay.
  2. TinoPay ghi nhận giao dịch (tiền vào hoặc tiền ra).
  3. TinoPay gửi POST kèm dữ liệu giao dịch (JSON) tới URL của từng webhook khớp điều kiện.
  4. Máy chủ của bạn trả HTTP 2xx: TinoPay đánh dấu thành công. Không thì TinoPay gửi lại, xem Phản hồi và thử lại.

Tạo webhook

  • Trên web TinoPay: menu Tích hợp → Webhooks → Thêm webhook; nút Tạo ngẫu nhiên sinh token 50 ký tự.
  • Bằng API merchant: route Tạo webhook (xem Bắt đầu với TinoPay để gọi API).
TrườngBắt buộcÝ nghĩa
namecótên webhook, tối đa 250 ký tự
bank_account_idcótài khoản ngân hàng cần theo dõi
urlcóURL nhận webhook, tối đa 250 ký tự; nên dùng HTTPS
eventcósự kiện kích hoạt, bảng dưới
tokenkhôngchuỗi xác minh; qua API bỏ trống thì tự sinh 50 ký tự
virtual_accountskhôngmảng số tài khoản ảo của tài khoản ngân hàng đó; đặt thì webhook chỉ chạy cho giao dịch vào các tài khoản ảo này, để trống: mọi tài khoản ảo
skip_if_no_codekhôngtrue: bỏ qua giao dịch không trích được mã thanh toán
statuskhôngactive (mặc định), inactive; chỉ webhook đang hoạt động mới được gửi
eventKích hoạt khi
ingiao dịch tiền vào (credit): nhận tiền, dùng để xác nhận thanh toán
outgiao dịch tiền ra (debit)
allmọi giao dịch, cả vào lẫn ra

Đa số tích hợp thanh toán chỉ cần in (mặc định trên web). Tạo được nhiều webhook cho nhiều tài khoản ngân hàng, nhiều hệ thống khác nhau.

Request TinoPay gửi

POST tới url của webhook, body JSON, kèm token của webhook ở hai header cùng giá trị:

POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
Authorization: Apikey <token>
ALLPAY-TOKEN: <token>
HeaderGiá trị
Content-Typeapplication/json
AuthorizationApikey <token>
ALLPAY-TOKEN<token>: tên header TinoPay đang gửi, giữ nguyên

Kiểm một trong hai header là đủ; nên dùng Authorization.

{
  "transaction_id": "20261020083338000006",
  "gateway": "BIDV",
  "transactionDate": "2026-10-20 08:33:38",
  "accountNumber": "96240000001",
  "code": "DH12345",
  "content": "DH12345 chuyen tien mua hang",
  "transferType": "in",
  "transferAmount": 150000,
  "referenceCode": "0551dTCO-82LiomOss",
  "virtualAccount": "9624QA01"
}
TrườngKiểuÝ nghĩa
transaction_idstring | nullmã giao dịch tại TinoPay
gatewaystringmã ngân hàng nguồn, ví dụ BIDV
transactionDatestring (Y-m-d H:i:s)thời điểm giao dịch
accountNumberstringsố tài khoản ngân hàng nhận hoặc phát sinh giao dịch
codestring | nullmã thanh toán trích từ nội dung chuyển khoản theo cấu trúc mã của bạn; null nếu không trích được
contentstringnội dung chuyển khoản, nguyên văn từ ngân hàng
transferTypestringin tiền vào, out tiền ra
transferAmountintsố tiền (VND)
referenceCodestring | nullmã tham chiếu của ngân hàng (core reference)
virtualAccountstring | nullsố tài khoản ảo nhận tiền; null nếu giao dịch không đi qua tài khoản ảo

code giúp khớp giao dịch với đơn hàng: đặt nội dung chuyển khoản là mã đơn (ví dụ tạo mã QR với memo=DH12345), TinoPay tự trích mã đó vào code. Cấu trúc mã khai ở web TinoPay (Cài đặt → Cấu trúc mã thanh toán) hoặc qua pay_codes của Sửa thông tin merchant.

Xác minh và xử lý

Webhook công khai trên Internet nên phải kiểm mỗi request có đúng do TinoPay gửi. TinoPay dùng token chung (shared token), không ký HMAC; token đi trong header dạng nguyên văn, nên URL webhook luôn phải là HTTPS.

  1. So token trong header Authorization (bỏ tiền tố Apikey ) với token của webhook bằng phép so sánh thời gian hằng; khác thì trả 401 và bỏ qua request.
  2. Chống trùng theo transaction_id (hoặc referenceCode): một giao dịch có thể đến nhiều lần (thử lại, gửi lại thủ công). Đã xử lý thì bỏ qua nhưng vẫn trả 200.
  3. Kiểm transferType để chỉ xử lý đúng chiều tiền cần.
  4. Đối chiếu transferAmount với đơn hàng tìm theo code, virtualAccount hoặc content trước khi đánh dấu đã thanh toán.
  5. Trả 2xx ngay khi nhận, việc nặng đưa vào hàng đợi.

Node.js (18+), không thư viện ngoài:

import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";

const token = Buffer.from(process.env.TINOPAY_WEBHOOK_TOKEN);
const seen = new Set(); // môi trường thật: lưu transaction_id vào cơ sở dữ liệu
const valid = (header) => {
  const got = Buffer.from(String(header ?? "").replace(/^Apikey /, ""));
  return got.length === token.length && timingSafeEqual(got, token);
};

createServer((req, res) => {
  if (req.method !== "POST" || !valid(req.headers.authorization)) return res.writeHead(401).end();
  let raw = "";
  req.on("data", (chunk) => (raw += chunk)).on("end", () => {
    const tx = JSON.parse(raw);
    if (!seen.has(tx.transaction_id)) {
      seen.add(tx.transaction_id);
      // ghi nhận thanh toán: tx.code, tx.transferAmount, tx.virtualAccount
    }
    res.writeHead(200).end("ok");
  });
}).listen(3000);

PHP:

<?php
$token = getenv("TINOPAY_WEBHOOK_TOKEN");
// Authorization: Apikey <token>. Có máy chủ (Apache + PHP-FPM) không chuyển header Authorization cho PHP:
// khi đó đọc header ALLPAY-TOKEN, cùng giá trị.
$auth = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
$got = substr($auth, 0, 7) === "Apikey " ? substr($auth, 7) : ($_SERVER["HTTP_ALLPAY_TOKEN"] ?? "");
if (!hash_equals($token, $got)) {
    http_response_code(401);
    exit;
}
$tx = json_decode(file_get_contents("php://input"), true);
// chống trùng theo $tx["transaction_id"], rồi ghi nhận $tx["code"], $tx["transferAmount"], $tx["virtualAccount"]
http_response_code(200);
echo "ok";

Phản hồi và thử lại

Phản hồi của bạnTinoPay hiểu là
HTTP 2xx (200–299)thành công, không gửi lại
mã khác (4xx, 5xx), không phản hồi hoặc hết thời gian chờthất bại, sẽ gửi lại

TinoPay chỉ xét mã HTTP, không xét body: trả 200 OK là đủ, {"success": true} là thói quen tốt nhưng không bắt buộc. Xử lý đồng bộ mà chậm thì request có thể hết thời gian chờ và bị tính là thất bại.

Lần gửi đầu không nhận được 2xx: TinoPay lên lịch gửi lại sau 5 phút, tác vụ gửi lại thử tối đa 3 lần. Vì vậy một giao dịch có thể tạo nhiều lần gọi tới webhook của bạn.

Log và gửi lại

Mỗi lần gửi được ghi log: payload gửi đi, câu trả lời của máy chủ bạn, mã HTTP (status_code), success, URL và phương thức, giao dịch liên quan.

  • Trên web TinoPay: menu Tích hợp → Lịch sử webhook (/webhookslog), bấm một dòng để xem chi tiết, nút Gọi lại để gửi lại. Ở trang chi tiết giao dịch, nút Gửi lại hook gửi lại tới mọi webhook, kênh thông báo và Google Sheets đang bật.
  • Log webhook: lọc status=failed để xem lần lỗi.
  • Gửi lại log webhook: gửi lại đúng request đó tới đúng webhook đó.
  • Bắn lại webhook cho giao dịch: gửi giao dịch tới mọi webhook đang bật của tài khoản ngân hàng.
  • Lịch sử giao dịch với webhook_status=failed hoặc none: giao dịch chưa gửi được.
  • Merchant tạm ngưng: giao dịch vẫn được ghi nhận nhưng không gửi webhook; khi mở lại, đối chiếu bằng lịch sử giao dịch.
  • Xoá webhook thì xoá luôn toàn bộ log của nó.

Danh sách kiểm tra

  • URL webhook chạy HTTPS.
  • Xác minh token ở mọi request.
  • Trả HTTP 2xx ngay khi nhận; việc nặng đưa vào hàng đợi.
  • Chống trùng bằng transaction_id hoặc referenceCode.
  • Kiểm transferType để chỉ xử lý đúng chiều tiền.
  • Đối chiếu định kỳ bằng API lịch sử giao dịch làm phương án dự phòng.