Bank transfer webhook — Postman collection
Collection: ../api/paygate-bank-transfer-webhook.postman_collection.json.
It simulates the email-notification service calling POST /api/v1/webhooks/bank-transfer, so you can test a bank transfer end to end without waiting for a real bank email. The service parses the Timo credit and sends it together with the email’s header fields (email object); PayGate alone verifies those headers (sender authentication, recipient code). The collection builds that body for you. This endpoint is internal (signed with a shared secret, not an app API key), so merchants don’t call it.
Import
- Postman -> Import -> select
api/paygate-bank-transfer-webhook.postman_collection.json. -
Open the collection’s Variables tab and fill in:
Variable Value base_urlPayGate base URL, no trailing slash bank_notify_secretSame value as BANK_NOTIFY_SECRETon PayGate andemail-notification. Used only to sign locally, never senttransfer_codePG-XXXXXXXXXXof a pendingbank_transfertransaction (shown on its/checkout/{token}/bankpage). Written into the transfercontentamountVND amount, must be >= the transaction amount to complete it underpaid_amountVND amount lower than the transaction amount (for the underpaid request) recipient_codeThe 10-digit code of the forward address paygate+{code}@{forward_domain}of the bank account the app selected. Secretforward_local_partSame as BANK_FORWARD_LOCAL_PARTon PayGate (defaultpaygate)forward_domainSame as BANK_FORWARD_DOMAINon PayGate (defaultshplinks.com)forwarder_addressMerchant mailbox used by the ARC requests; must be in BANK_FORWARDER_ALLOWLISTon PayGate when that list is not empty (defaultmerchant@gmail.com)account_numberReceiving account number as parsed from the email. Leave empty to send null(Timo emails carry none) - Save the collection so the values persist.
Request body
{
"message_id": "<sha256 hex of bank|amount|occurred_at|content>",
"transfer_code": "PG-AB12CD34EF", "amount": 70000, "account_number": null,
"bank": "timo", "content": "PG-AB12CD34EF FT... tu 190... tai TCB.",
"received_at": "2026-10-02T08:09:00.000Z",
"email": {
"message_id_header": "<...@timo.vn>", "from": "support@timo.vn", "subject": "...", "date": "...", "return_path": null,
"to": [], "cc": [], "delivered_to": ["paygate+{code}@shplinks.com"], "x_original_to": [], "envelope_to": [],
"x_forwarded_to": [], "x_forwarded_for": [], "received_for": [],
"authentication_results": "mx.example; dkim=pass header.d=timo.vn; spf=pass; dmarc=pass header.from=timo.vn",
"arc_seals": [], "arc_authentication_results": []
}
}
The pre-request script builds this body, stores the final JSON string in the _body collection variable (the request’s raw body) and signs that exact string. The direct requests use a DKIM pass for timo.vn; the ARC requests mimic a Gmail auto-forward (provider sees dkim=fail dmarc=fail arc=pass, ARC seals by google.com, ARC-Authentication-Results i=1 with dkim+dmarc pass for timo.vn, forwarder in x_forwarded_for / return_path).
How signing works
Requests carry X-Timestamp and X-Signature as template variables. The collection-level pre-request script fills them before each send:
signed_payload = "{timestamp}.{raw_body}"
signature = hex(HMAC_SHA256(signed_payload, bank_notify_secret))
The script computes the final body string itself (Postman only substitutes `` after the script runs). The timestamp must be within +-5 minutes of the server clock.
Requests
| Request | Expected response |
|---|---|
| Complete payment (valid) | 200 processed. The transaction becomes completed. A transaction completes only once; use a new transfer_code to run it again |
| Complete payment via Gmail ARC forward | 200 processed (forwarder allowlisted, or the allowlist is empty) |
Replay same message [replay] | 200 duplicate (or already_completed). Re-sends the previous body; run it right after another request. Keep [replay] in the request name, the script keys on it |
| Underpaid | 200 underpaid. The transaction stays pending and gets a payment issue shown in the portal |
| Wrong recipient_code | Production: 200 rejected / invalid_recipient, transaction untouched. Development: processed (see note) |
| Missing recipient_code | No header carries a code. Production: 200 rejected / invalid_recipient. Development: processed (see note) |
| Untrusted sender | from = attacker@evil.test, no DKIM pass. 200 rejected / untrusted_sender in every environment |
| ARC with untrusted sealer | ARC chain sealed by evil.test. 200 rejected / untrusted_sender |
Responses are the PayGate envelope {"success":true,"data":{"status":"...","reason":"..."}}; reason is optional.
Development mode: with
APP_ENV=developmentPayGate skips the recipient code check (sender authentication still applies), so the wrong/missingrecipient_coderequests are expected to returnprocessedthere. Run against a production-mode PayGate to see the rejection.
Troubleshooting
| Symptom | Likely cause |
|---|---|
400 invalid_signature | bank_notify_secret differs from the server’s BANK_NOTIFY_SECRET (an empty server secret rejects everything), or the body was edited after signing |
400 invalid_signature with a correct secret | Clock skew > 5 minutes |
rejected on the valid request | recipient_code isn’t the current code of the account the app selected (regenerating a code invalidates the old one), or forward_local_part / forward_domain differ from PayGate’s, or BANK_SENDER_ALLOWLIST doesn’t include timo.vn; for the ARC request, forwarder_address missing from BANK_FORWARDER_ALLOWLIST gives untrusted_sender |
ignored | The transaction is unknown, isn’t pending (already completed, failed or canceled), or the account is unavailable (see reason) |
recipient_code is a secret: don’t commit a real value into the collection or share an exported copy that contains it.