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

  1. Postman -> Import -> select api/paygate-bank-transfer-webhook.postman_collection.json.
  2. Open the collection’s Variables tab and fill in:

    Variable Value
    base_url PayGate base URL, no trailing slash
    bank_notify_secret Same value as BANK_NOTIFY_SECRET on PayGate and email-notification. Used only to sign locally, never sent
    transfer_code PG-XXXXXXXXXX of a pending bank_transfer transaction (shown on its /checkout/{token}/bank page). Written into the transfer content
    amount VND amount, must be >= the transaction amount to complete it
    underpaid_amount VND amount lower than the transaction amount (for the underpaid request)
    recipient_code The 10-digit code of the forward address paygate+{code}@{forward_domain} of the bank account the app selected. Secret
    forward_local_part Same as BANK_FORWARD_LOCAL_PART on PayGate (default paygate)
    forward_domain Same as BANK_FORWARD_DOMAIN on PayGate (default shplinks.com)
    forwarder_address Merchant mailbox used by the ARC requests; must be in BANK_FORWARDER_ALLOWLIST on PayGate when that list is not empty (default merchant@gmail.com)
    account_number Receiving account number as parsed from the email. Leave empty to send null (Timo emails carry none)
  3. 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=development PayGate skips the recipient code check (sender authentication still applies), so the wrong/missing recipient_code requests are expected to return processed there. 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.


This site uses Just the Docs, a documentation theme for Jekyll.