Skip to content

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.

type is <object>.<status>, using the status of the payment at the time of sending.

EventSent when
payin.succeededThe transfer was matched (bank statement, slip or operator confirmation)
payin.expiredThe pay-in expired without payment
payin.canceledThe platform canceled the pay-in
payout.succeededThe payout was transferred
payout.canceledThe payout was canceled by an operator, after a rejected review, or by the platform
payout.pendingA 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 webhookUrl get no webhooks.
  • More event types may be added. Reply 2xx to types you do not know, and always decide based on data.status.
{
"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.

HeaderValue
Content-Typeapplication/json; charset=utf-8
X-Webhook-IdEvent id (same as id in the body)
X-Webhook-TimestampUnix time (seconds) when the event was signed
X-Webhook-Signaturet=<timestamp>,v1=<hex HMAC-SHA256>
User-AgentIdentifies the sender. Do not use it for authentication

Webhooks are signed with the same secret you use to sign API requests.

v1 = hex( HMAC-SHA256( secret, t + "." + raw_body ) )
  1. Read the body as raw bytes before parsing JSON (if your framework parses and re-serialises it, the signature will not match).

  2. Extract t and v1 from the X-Webhook-Signature header.

  3. Compute the expected v1 and compare in constant time.

  4. Reject events whose t is more than 5 minutes away from now, to prevent replays.

  5. If the check fails, reply 400 and 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
});

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=8f10d9e7bbb3bb92adf707670fbce69c9bcb88772b1b92e7cd216d4f032e9275
  • The platform waits at most 10 seconds for your answer. Reply 2xx as 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/https URLs on public IP addresses are called. URLs pointing to localhost, 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).

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.

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, reply 200 and 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 that data.currency and the amount are what you expect.

Because there are no automatic retries, run a scheduled job (every 5-10 minutes, for example) that:

  • calls GET /v1/payins/{id} or GET /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