Webhooks
Payload shape, headers, signature verification, the retry policy and handling duplicate deliveries.
Generated from public-api.mdVersion 1.2.0Last updated 2026-09-20
#Webhooks
When you provide a webhook_url on a deposit or withdraw, we POST payment
updates to it.
webhook_urlis per request. There is no way to register a default webhook URL through this API. A stored webhook configuration does exist (POST /v1/webhooks/config), but it is only reachable with a dashboard login (IAM JWT), is not routed on the public HMAC gateway, and today only governs thereport.*events — transaction webhooks always use thewebhook_urlyou put on the individual request. If you omit it, no webhook is sent at all.
#When a webhook is sent
A transaction webhook fires when a transaction reaches completed, failed,
expired or refunded. There is no webhook for pending, processing or
pending_approval.
#Transaction webhook payload
The transaction webhook payload is flat — it is not wrapped in an event envelope:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "deposit",
"amount": 1500.00,
"paidAmount": 1500.42,
"fee": 37.50,
"netAmount": 1462.50,
"bankAccountNo": "1234567890",
"bankAccountName": "SOMCHAI JAIDEE",
"bankCode": "014",
"bankName": "ธนาคารไทยพาณิชย์",
"currency": "THB",
"referenceNo": "ORD888888888",
"status": "success",
"timestamp": "2026-09-20T17:35:00+07:00",
"paidAt": "2026-09-20T17:34:55.123456789+07:00"
}#Webhook Payload Fields
| Field | Type | Description |
|---|---|---|
id | string | Transaction ID |
type | string | deposit or withdraw |
amount | number | The amount originally requested |
paidAmount | number | Amount actually transferred (the unique-satang QR amount). 0 unless status is success |
fee | number | Processing fee charged |
netAmount | number | amount - fee |
bankAccountNo | string | Payer account (deposit) / destination account (withdraw) |
bankAccountName | string | Account holder name |
bankCode | string | Bank code |
bankName | string | Bank name in Thai, resolved from bankCode (echoes the code if unknown) |
currency | string | Currency code |
referenceNo | string | Your reference_id |
status | string | success, failed, expired or refunded |
timestamp | string | Time of this delivery attempt (RFC 3339, Asia/Bangkok) — changes on retry |
paidAt | string | Payment completion time (RFC 3339 nano); empty string if not paid |
failureReason | string | Present only when status is failed or refunded |
#Webhook status values and events
status | X-Webhook-Event | Transaction status |
|---|---|---|
success | transaction.completed | completed |
failed | transaction.failed | failed |
expired | transaction.expired | expired |
refunded | transaction.refunded | refunded |
expired is a live value, not a legacy alias for failed. A refunded
webhook means a previously reported success has been reversed — un-credit the
order; failureReason carries the operator's reason.
#Report events (different shape)
report.daily_summary — the once-a-day balance summary — is delivered on the
same channel with the same signing and retry rules, but its body is an
envelope:
{
"id": "daily_summary:<merchant_id>:2026-09-19",
"event": "report.daily_summary",
"merchant_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"data": { "...": "the daily-summary report body" },
"timestamp": "2026-09-20T21:35:23+07:00"
}Distinguish the two shapes by the X-Webhook-Event header, or by the presence
of event/data. Report events are documented in
merchant-reports-api.md; subscribing to them
requires a dashboard login today.
#Webhook headers
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | Lowercase-hex HMAC-SHA256 — see below |
X-Webhook-Timestamp | Unix timestamp (seconds) of this attempt |
X-Webhook-Event | transaction.completed / .failed / .expired / .refunded, or report.daily_summary |
X-Transaction-ID | Transaction ID, or for report events the deterministic delivery key |
There is no X-Webhook-ID and no custom User-Agent.
#Webhook Verification
X-Webhook-Signature = lowercase_hex(HMAC-SHA256(SecretKey, X-Webhook-Timestamp + "|" + raw_body))The signing key is your API secret key — the same sk_… value you use to
sign requests. It is not the secret field returned by the dashboard's
webhook-configuration endpoint (that value is unused for signing).
Verify against the raw bytes you received, never a re-serialised copy:
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret_key: str) -> bool:
expected = hmac.new(
secret_key.encode(),
timestamp.encode() + b"|" + raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)#Webhook Retry Policy
- A delivery succeeds on any 2xx response within a 10 second timeout.
- A failed delivery is retried on a fixed 1 minute interval (not
exponential, despite the
retry_backoff: "exponential"label exposed by the dashboard's webhook configuration). - 5 attempts in total by default, after which the delivery is marked failed and not retried.
- The retry worker sweeps every minute, so the real gap between attempts is 1–2 minutes.
#Duplicate deliveries — make your handler idempotent
Transaction webhooks are at-least-once and are not deduplicated. Only
report.* events carry a deterministic delivery key that suppresses duplicates.
A transaction webhook may therefore arrive more than once for the same
transaction and status — for example after an internal re-publish, a manual
resend by support, or a message replay.
Key your handler on id + status (or X-Transaction-ID + X-Webhook-Event)
and make repeats a no-op. Do not treat a second success for the same id as a
second payment.
Note also that timestamp in the body is refreshed on every attempt, so it is
not usable as a deduplication key.

