Webhooks
When a pay-in or payout reaches an important status, the platform sends a JSON POST to the webhookUrl you gave when creating it. There is no account-wide webhook setting: each payment uses its own URL.
Events
Section titled “Events”type is <object>.<status>, using the status of the payment at the time of sending.
| Event | Sent when |
|---|---|
payin.succeeded | The transfer was matched (bank statement, slip or operator confirmation) |
payin.expired | The pay-in expired without payment |
payin.canceled | The platform canceled the pay-in |
payout.succeeded | The payout was transferred |
payout.canceled | The payout was canceled by an operator, after a rejected review, or by the platform |
payout.pending | A payout that was in_review was approved and queued for processing |
- Cancels you make through the API do not send a webhook; the API response is your confirmation.
- Payouts created without
webhookUrlget no webhooks. - More event types may be added. Reply 2xx to types you do not know, and always decide based on
data.status.
Payload
Section titled “Payload”{ "id": "evt_1d6c0f3a9b2e4c7d8e1f2a3b4c5d6e7f", "type": "payin.succeeded", "createdAt": "2026-01-01T00:05:01.000Z", "data": { "object": "payin", "id": "pin_3f2b1c0d9e8f4a7b8c6d5e4f3a2b1c0d", "reference": "order-10001", "payerId": "payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d", "payer": { "object": "payer", "id": "payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d", "...": "..." }, "requestedAmount": "150.00", "amount": "150.37", "fee": "2.25", "currency": "THB", "method": "promptpay_qr", "status": "succeeded", "failureReason": null, "webhookUrl": "https://shop.example.com/webhooks/payments", "returnUrl": "https://shop.example.com/orders/10001", "checkoutUrl": "https://pay.example.com/3f2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "payerAccount": { "bankCode": "SCB", "accountNumber": "1234567890", "accountName": "สมชาย สบายดี" }, "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:05:00.000Z" }}data is the pay-in or payout object, as returned by the API but without payTo and paymentImage.
Headers
Section titled “Headers”| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Webhook-Id | Event id (same as id in the body) |
X-Webhook-Timestamp | Unix time (seconds) when the event was signed |
X-Webhook-Signature | t=<timestamp>,v1=<hex HMAC-SHA256> |
User-Agent | Identifies the sender. Do not use it for authentication |
Verifying the signature
Section titled “Verifying the signature”Webhooks are signed with the same secret you use to sign API requests.
v1 = hex( HMAC-SHA256( secret, t + "." + raw_body ) )-
Read the body as raw bytes before parsing JSON (if your framework parses and re-serialises it, the signature will not match).
-
Extract
tandv1from theX-Webhook-Signatureheader. -
Compute the expected
v1and compare in constant time. -
Reject events whose
tis more than 5 minutes away from now, to prevent replays. -
If the check fails, reply
400and stop.
// Express. Mount the raw body parser on this route only.import crypto from 'node:crypto';import express from 'express';
const SECRET = process.env.PARTNER_API_SECRET;
export function verifyWebhook(signatureHeader, rawBody, toleranceSeconds = 300) { const parts = {}; for (const item of String(signatureHeader ?? '').split(',')) { const i = item.indexOf('='); if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim(); } if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false; const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(rawBody).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && crypto.timingSafeEqual(a, b);}
const app = express();app.post('/webhooks/payments', express.raw({ type: '*/*' }), async (req, res) => { if (!verifyWebhook(req.get('X-Webhook-Signature'), req.body)) return res.status(400).end(); const event = JSON.parse(req.body.toString('utf8')); await saveEventForProcessing(event); // e.g. insert into a table or a queue res.status(200).end(); // answer fast, process afterwards});<?phpfunction verify_webhook(string $header, string $rawBody, string $secret, int $tolerance = 300): bool{ $parts = []; foreach (explode(',', $header) as $item) { $kv = explode('=', trim($item), 2); if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; } } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; } if (abs(time() - (int) $parts['t']) > $tolerance) { return false; } $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']);}
$raw = file_get_contents('php://input');if (!verify_webhook($_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', $raw, getenv('PARTNER_API_SECRET'))) { http_response_code(400); exit;}$event = json_decode($raw, true);save_event_for_processing($event); // e.g. insert into a table or a queuehttp_response_code(200);# Flaskimport hashlib, hmac, json, os, timefrom flask import Flask, request
API_SECRET = os.environ["PARTNER_API_SECRET"]app = Flask(__name__)
def verify_webhook(signature_header, raw_body, tolerance=300): parts = dict(p.strip().split("=", 1) for p in (signature_header or "").split(",") if "=" in p) t, v1 = parts.get("t", ""), parts.get("v1", "") if not t.isdigit() or not v1 or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(API_SECRET.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)
@app.post("/webhooks/payments")def payments_webhook(): raw = request.get_data() # raw bytes, before any JSON parsing if not verify_webhook(request.headers.get("X-Webhook-Signature"), raw): return "", 400 event = json.loads(raw) save_event_for_processing(event) # e.g. insert into a table or a queue return "", 200Test vector
Section titled “Test vector”Secret 3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11, body (no spaces or newlines):
{"id":"evt_1d6c0f3a9b2e4c7d8e1f2a3b4c5d6e7f","type":"payin.succeeded","createdAt":"2026-01-01T00:05:01.000Z","data":{"object":"payin","id":"pin_3f2b1c0d9e8f4a7b8c6d5e4f3a2b1c0d","status":"succeeded"}}Expected header (disable the 5-minute age check while testing, since this timestamp is old):
X-Webhook-Signature: t=1767225901,v1=8f10d9e7bbb3bb92adf707670fbce69c9bcb88772b1b92e7cd216d4f032e9275Delivery and retries
Section titled “Delivery and retries”- The platform waits at most 10 seconds for your answer. Reply
2xxas fast as possible and do slow work afterwards. - There is no automatic retry. If your endpoint is down, slow or returns an error, the event is not sent again by itself.
- Redirects are not followed, and only
http/httpsURLs on public IP addresses are called. URLs pointing tolocalhost, private or link-local addresses are skipped. For local development, use a tunnel with a public URL. - Your endpoint’s response status and body are recorded and shown in the payment details in the console (callback log).
Getting an event sent again
Section titled “Getting an event sent again”If you missed a webhook, open the payment in the console and click Send callback again. The button works for finished payments: pay-ins that are succeeded, expired or canceled, and payouts that are succeeded or canceled. To have many payments re-sent, contact the team with the list of ids.
Idempotent processing
Section titled “Idempotent processing”The same payment can be notified more than once, or after you already fetched its status with GET. Make your handler safe to repeat:
- De-duplicate on
data.id+data.status, for example with a unique index. If it was already processed, reply200and stop. - Once an order reached a final state (paid or canceled), never move it back because of a late event.
- Credit your user in the same database transaction that records the event as processed.
- Find your order with
data.reference, and check thatdata.currencyand the amount are what you expect.
Reconciliation
Section titled “Reconciliation”Because there are no automatic retries, run a scheduled job (every 5-10 minutes, for example) that:
- calls
GET /v1/payins/{id}orGET /v1/payouts/{id}for payments that have been open in your system for longer than usual; - lists recently succeeded payments to catch missed webhooks.
GET /v1/payins?status=succeeded&createdFrom=2026-01-01T00:00:00Z&limit=100