PayMeNow

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_url is 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 the report.* events — transaction webhooks always use the webhook_url you 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:

json
{
  "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

FieldTypeDescription
idstringTransaction ID
typestringdeposit or withdraw
amountnumberThe amount originally requested
paidAmountnumberAmount actually transferred (the unique-satang QR amount). 0 unless status is success
feenumberProcessing fee charged
netAmountnumberamount - fee
bankAccountNostringPayer account (deposit) / destination account (withdraw)
bankAccountNamestringAccount holder name
bankCodestringBank code
bankNamestringBank name in Thai, resolved from bankCode (echoes the code if unknown)
currencystringCurrency code
referenceNostringYour reference_id
statusstringsuccess, failed, expired or refunded
timestampstringTime of this delivery attempt (RFC 3339, Asia/Bangkok) — changes on retry
paidAtstringPayment completion time (RFC 3339 nano); empty string if not paid
failureReasonstringPresent only when status is failed or refunded

#Webhook status values and events

statusX-Webhook-EventTransaction status
successtransaction.completedcompleted
failedtransaction.failedfailed
expiredtransaction.expiredexpired
refundedtransaction.refundedrefunded

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:

json
{
  "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

HeaderDescription
Content-Typeapplication/json
X-Webhook-SignatureLowercase-hex HMAC-SHA256 — see below
X-Webhook-TimestampUnix timestamp (seconds) of this attempt
X-Webhook-Eventtransaction.completed / .failed / .expired / .refunded, or report.daily_summary
X-Transaction-IDTransaction ID, or for report events the deterministic delivery key

There is no X-Webhook-ID and no custom User-Agent.

#Webhook Verification

text
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:

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