# 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](https://pay.tino.vn/docs/webhooks) 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](#phan-hoi-va-thu-lai).

## 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](/docs/api/tinopay-post-webhooks) (xem [Bắt đầu với TinoPay](/docs/tinopay-start) để gọi API).

| Trường | Bắt buộc | Ý nghĩa |
| --- | --- | --- |
| `name` | có | tên webhook, tối đa 250 ký tự |
| `bank_account_id` | có | tài khoản ngân hàng cần theo dõi |
| `url` | có | URL nhận webhook, tối đa 250 ký tự; nên dùng HTTPS |
| `event` | có | sự kiện kích hoạt, bảng dưới |
| `token` | không | chuỗi xác minh; qua API bỏ trống thì tự sinh 50 ký tự |
| `virtual_accounts` | không | mả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_code` | không | `true`: bỏ qua giao dịch không trích được mã thanh toán |
| `status` | không | `active` (mặc định), `inactive`; chỉ webhook đang hoạt động mới được gửi |

| `event` | Kích hoạt khi |
| --- | --- |
| `in` | giao dịch tiền vào (credit): nhận tiền, dùng để xác nhận thanh toán |
| `out` | giao dịch tiền ra (debit) |
| `all` | mọ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ị:

```http
POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
Authorization: Apikey <token>
ALLPAY-TOKEN: <token>
```

| Header | Giá trị |
| --- | --- |
| `Content-Type` | `application/json` |
| `Authorization` | `Apikey <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`.

```json
{
  "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ường | Kiểu | Ý nghĩa |
| --- | --- | --- |
| `transaction_id` | string \| null | mã giao dịch tại TinoPay |
| `gateway` | string | mã ngân hàng nguồn, ví dụ `BIDV` |
| `transactionDate` | string (`Y-m-d H:i:s`) | thời điểm giao dịch |
| `accountNumber` | string | số tài khoản ngân hàng nhận hoặc phát sinh giao dịch |
| `code` | string \| null | mã 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 |
| `content` | string | nội dung chuyển khoản, nguyên văn từ ngân hàng |
| `transferType` | string | `in` tiền vào, `out` tiền ra |
| `transferAmount` | int | số tiền (VND) |
| `referenceCode` | string \| null | mã tham chiếu của ngân hàng (core reference) |
| `virtualAccount` | string \| null | số 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](/docs/tinopay-api#tao-ma-vietqr) 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](/docs/api/tinopay-patch-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:

```javascript
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
<?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ạn | TinoPay 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](/docs/api/tinopay-get-webhook-logs): lọc `status=failed` để xem lần lỗi.
- [Gửi lại log webhook](/docs/api/tinopay-post-webhook-logs-log-resend): gửi lại đúng request đó tới đúng webhook đó.
- [Bắn lại webhook cho giao dịch](/docs/api/tinopay-post-transactions-transaction-resend-webhook): 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](/docs/api/tinopay-get-transactions) 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](/docs/tinopay-api#lich-su-giao-dich) làm phương án dự phòng.
