# Receiving TinoPay webhooks

For every incoming (or outgoing) transaction on a linked bank account, TinoPay sends a `POST` request to your webhook URL; the JSON body is the transaction, and headers carry the webhook's token so you can verify it. This is the recommended integration for flows that need instant payment confirmation (payment gateways, automatic top-ups, order confirmation), instead of polling the API.

Updated: 2026-10-25. Sources: the [TinoPay webhook documentation](https://pay.tino.vn/docs/webhooks) and the TinoPay API v1 documentation.

## Flow

1. The customer transfers money to a bank account connected to TinoPay.
2. TinoPay records the transaction (incoming or outgoing).
3. TinoPay sends a `POST` with the transaction data (JSON) to the URL of every matching webhook.
4. Your server answers HTTP 2xx: TinoPay marks the delivery successful. Otherwise TinoPay sends it again, see [Responses and retries](#responses-and-retries).

## Creating a webhook

- In the TinoPay web app: menu Tích hợp (Integrations) → Webhooks → Thêm webhook (Add webhook); the Tạo ngẫu nhiên (Generate) button creates a 50-character token.
- With the merchant API: the [Create a webhook](/en/docs/api/tinopay-post-webhooks) route (see [Getting started with TinoPay](/en/docs/tinopay-start) for calling the API).

| Field | Required | Meaning |
| --- | --- | --- |
| `name` | yes | webhook name, up to 250 characters |
| `bank_account_id` | yes | the bank account to watch |
| `url` | yes | the receiving URL, up to 250 characters; use HTTPS |
| `event` | yes | the triggering event, table below |
| `token` | no | verification string; through the API, leave empty to get a generated 50-character token |
| `virtual_accounts` | no | array of that bank account's virtual account numbers; when set, the webhook fires only for transactions to these virtual accounts; empty: every virtual account |
| `skip_if_no_code` | no | `true`: skip transactions without an extracted payment code |
| `status` | no | `active` (default), `inactive`; only active webhooks are sent |

| `event` | Fires on |
| --- | --- |
| `in` | incoming transactions (credit): money received, used to confirm payments |
| `out` | outgoing transactions (debit) |
| `all` | every transaction, incoming and outgoing |

Most payment integrations only need `in` (the web app's default). You can create several webhooks for different bank accounts and systems.

## The request TinoPay sends

A `POST` to the webhook's `url` with a JSON body, carrying the webhook's token in two headers with the same value:

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

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `Authorization` | `Apikey <token>` |
| `ALLPAY-TOKEN` | `<token>`: the header name TinoPay sends, keep it as is |

Checking either header is enough; prefer `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"
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `transaction_id` | string \| null | transaction code at TinoPay |
| `gateway` | string | source bank code, e.g. `BIDV` |
| `transactionDate` | string (`Y-m-d H:i:s`) | when the transaction happened |
| `accountNumber` | string | the bank account number that received or made the transaction |
| `code` | string \| null | payment code extracted from the transfer description with your code structure; `null` when none is found |
| `content` | string | the transfer description, verbatim from the bank |
| `transferType` | string | `in` incoming, `out` outgoing |
| `transferAmount` | int | amount (VND) |
| `referenceCode` | string \| null | the bank's reference code (core reference) |
| `virtualAccount` | string \| null | the virtual account that received the money; `null` when the transfer did not go through one |

`code` lets you match a transaction with an order: use the order code as the transfer description (e.g. a [QR code](/en/docs/tinopay-api#vietqr-codes) with `memo=DH12345`) and TinoPay extracts it into `code`. The code structure is set in the TinoPay web app (Cài đặt → Cấu trúc mã thanh toán, Settings → Payment code structure) or through `pay_codes` of [Update merchant details](/en/docs/api/tinopay-patch-merchant).

## Verifying and processing

The webhook is public on the Internet, so check that every request really comes from TinoPay. TinoPay uses a shared token, not an HMAC signature; the token travels in plain text in a header, so the webhook URL must always be HTTPS.

1. Compare the token in the `Authorization` header (without the `Apikey ` prefix) with the webhook's token using a constant-time comparison; if they differ, answer 401 and ignore the request.
2. Deduplicate on `transaction_id` (or `referenceCode`): one transaction can arrive more than once (retries, manual resends). If it was already processed, skip it but still answer 200.
3. Check `transferType` so you only handle the direction you need.
4. Check `transferAmount` against the order found by `code`, `virtualAccount` or `content` before marking it paid.
5. Answer 2xx as soon as you receive it and queue heavy work.

Node.js (18+), no external libraries:

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

const token = Buffer.from(process.env.TINOPAY_WEBHOOK_TOKEN);
const seen = new Set(); // in production: store transaction_id in your database
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);
      // record the payment: tx.code, tx.transferAmount, tx.virtualAccount
    }
    res.writeHead(200).end("ok");
  });
}).listen(3000);
```

PHP:

```php
<?php
$token = getenv("TINOPAY_WEBHOOK_TOKEN");
// Authorization: Apikey <token>. Some servers (Apache + PHP-FPM) do not pass the Authorization header to PHP:
// then read the ALLPAY-TOKEN header, which carries the same value.
$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);
// deduplicate on $tx["transaction_id"], then record $tx["code"], $tx["transferAmount"], $tx["virtualAccount"]
http_response_code(200);
echo "ok";
```

## Responses and retries

| Your answer | TinoPay reads it as |
| --- | --- |
| HTTP 2xx (200–299) | success, not sent again |
| any other status (4xx, 5xx), no answer or timeout | failure, will be sent again |

TinoPay only looks at the HTTP status, not the body: `200 OK` is enough; `{"success": true}` is a good habit but not required. Slow synchronous processing can time out and count as a failure.

When the first delivery gets no 2xx, TinoPay schedules a resend after 5 minutes, and the resend job tries at most 3 times. One transaction can therefore produce several calls to your webhook.

## Logs and resends

Every delivery is logged: the payload sent, your server's answer, the HTTP status (`status_code`), `success`, the URL and method, the related transaction.

- In the TinoPay web app: menu Tích hợp → Lịch sử webhook (Integrations → Webhook history, `/webhookslog`); click a row for details, and Gọi lại (Call again) to resend. On a transaction's detail page, Gửi lại hook (Resend hook) resends to every active webhook, notification channel and Google Sheets integration.
- [Webhook logs](/en/docs/api/tinopay-get-webhook-logs): filter with `status=failed` to see failures.
- [Resend a webhook log](/en/docs/api/tinopay-post-webhook-logs-log-resend): resend exactly that request to the same webhook.
- [Resend webhooks for a transaction](/en/docs/api/tinopay-post-transactions-transaction-resend-webhook): send the transaction to every active webhook of the bank account.
- [Transaction history](/en/docs/api/tinopay-get-transactions) with `webhook_status=failed` or `none`: transactions not delivered yet.
- Suspended merchant: transactions are still recorded but no webhooks are sent; after reactivation, reconcile with the transaction history.
- Deleting a webhook also deletes all of its logs.

## Checklist

- The webhook URL runs on HTTPS.
- Verify the token on every request.
- Answer HTTP 2xx on receipt; queue heavy work.
- Deduplicate on `transaction_id` or `referenceCode`.
- Check `transferType` to handle only the direction you need.
- Reconcile regularly with the [transaction history API](/en/docs/tinopay-api#transaction-history) as a fallback.
