Tino Developers
View .md

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 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.

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 route (see Getting started with TinoPay for calling the API).
FieldRequiredMeaning
nameyeswebhook name, up to 250 characters
bank_account_idyesthe bank account to watch
urlyesthe receiving URL, up to 250 characters; use HTTPS
eventyesthe triggering event, table below
tokennoverification string; through the API, leave empty to get a generated 50-character token
virtual_accountsnoarray 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_codenotrue: skip transactions without an extracted payment code
statusnoactive (default), inactive; only active webhooks are sent
eventFires on
inincoming transactions (credit): money received, used to confirm payments
outoutgoing transactions (debit)
allevery 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:

POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
Authorization: Apikey <token>
ALLPAY-TOKEN: <token>
HeaderValue
Content-Typeapplication/json
AuthorizationApikey <token>
ALLPAY-TOKEN<token>: the header name TinoPay sends, keep it as is

Checking either header is enough; prefer 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"
}
FieldTypeMeaning
transaction_idstring | nulltransaction code at TinoPay
gatewaystringsource bank code, e.g. BIDV
transactionDatestring (Y-m-d H:i:s)when the transaction happened
accountNumberstringthe bank account number that received or made the transaction
codestring | nullpayment code extracted from the transfer description with your code structure; null when none is found
contentstringthe transfer description, verbatim from the bank
transferTypestringin incoming, out outgoing
transferAmountintamount (VND)
referenceCodestring | nullthe bank's reference code (core reference)
virtualAccountstring | nullthe 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 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.

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:

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
$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 answerTinoPay reads it as
HTTP 2xx (200–299)success, not sent again
any other status (4xx, 5xx), no answer or timeoutfailure, 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: filter with status=failed to see failures.
  • Resend a webhook log: resend exactly that request to the same webhook.
  • Resend webhooks for a transaction: send the transaction to every active webhook of the bank account.
  • Transaction history 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 as a fallback.