Skip to main content

Payment Webhooks

Instead of polling Booking Result until a payment settles, you can let us tell you. We send an HTTP request to your server the moment a payment succeeds or fails.

  • Direction: Onlynx → your server
  • Method: POST
  • Endpoint: the GENERAL URL registered on your partner account
  • Body: JSON

These events fire for every product type — hotel, flight, tour, attraction, transfer — because every product is paid through the same endpoint.

:::info Delivered to your existing GENERAL endpoint Payment events arrive at the same URL that already receives your login and register events, and (if you use flights) the flight lifecycle events. Branch on the payload's type field and ignore any type you do not recognise. :::

Events​

typeFires when
payment_successThe payment settled and the booking is marked paid.
payment_failedThe gateway reported the payment as failed and the booking was rejected.

:::caution Absence of payment_failed does not mean success payment_success fires for every payment gateway. payment_failed only fires for gateways that actually report failures back to us — several simply never call back when a payment does not complete, so a failed payment can produce no webhook at all.

Keep the booking's hold window in mind: an unpaid booking is cancelled automatically after about 30 minutes. Treat "no payment_success before the hold expires" as the real failure signal, and payment_failed as an early warning when a gateway bothers to send one. :::

Payload​

{
"type": "payment_success",
"event_id": "pwh_65zr6qkrmzmeoppe",
"occurred_at": "2026-09-24T03:51:40+00:00",
"user_id": "0G7KYr78Nb",
"booking_number": "1926B5DA6DD92805",
"payment_ref": "34513835511707",
"payment_status": "paid",
"grand_total": 36949.88,
"grand_total_currency": "THB",
"payment_record": {
"grand_total": 36950,
"payment_currency": "MMK",
"payment_exchange_rate": 1.0
}
}
FieldTypeDescription
typeStringpayment_success or payment_failed.
event_idStringUnique per event, identical across retries. Use for deduplication.
occurred_atStringISO 8601 with offset. When the payment settled, not when it was delivered.
user_idString | nullHashed customer ID. null for a booking made without a customer account.
booking_numberStringThe booking this payment belongs to.
payment_refStringThe invoice_no used with the gateway.
payment_statusStringBooking payment status at the time of the event, e.g. paid, rejected.
grand_totalNumberBooking total, always in THB.
grand_total_currencyStringAlways THB.
payment_record.grand_totalIntegerThe amount actually charged, in the currency the customer paid in.
payment_record.payment_currencyString | nullCurrency actually charged. null when charged in THB.
payment_record.payment_exchange_rateNumberTHB → payment_currency rate used. 1 when no conversion applied.

:::tip Two totals, on purpose grand_total is always THB so you can reconcile across bookings without currency maths. payment_record.grand_total is what the customer was actually charged. They differ whenever the booking was paid in a non-THB currency. :::

Headers​

HeaderExampleDescription
Content-Typeapplication/json
X-Webhook-Eventpayment_successSame value as type. Lets you route before parsing.
X-Webhook-Event-Idpwh_65zr6qkrmzmeoppeSame value as event_id.
X-Webhook-Signaturesha256=80f25af4…HMAC of the raw body.

Verifying the signature​

Every request is signed with HMAC-SHA256 over the raw request body, keyed on the same private secret you use to build your Partner-Access-Token. No new credential is issued.

expected = "sha256=" + hmac_sha256(raw_body, partner_secret)
valid = constant_time_compare(expected, headers["X-Webhook-Signature"])

:::danger Sign the bytes you received, not a re-serialised object Compute the HMAC over the raw body exactly as it arrived. If you parse the JSON and re-encode it first, key order and whitespace change and the digest will never match.

In PHP read file_get_contents('php://input'); in Express use express.raw({ type: 'application/json' }) rather than express.json(). :::

$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $partnerSecret);

if (! hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}

$payload = json_decode($raw, true);

Use a constant-time comparison — PHP hash_equals(), Node crypto.timingSafeEqual(). Reject any request whose signature does not match. The header is omitted entirely if your account has no secret on file; treat a missing signature as a failed verification, not as permission to skip the check.

Delivery and retries​

Timeout15 seconds
SuccessHTTP 2xx and a JSON body containing "success": true
RetriesEvery 5 minutes for 30 minutes (about 6 attempts)
After thatLogged as failed and abandoned — not redelivered later

:::warning A 200 alone is not enough Returning 200 OK with an empty body counts as a failure and will be retried. Your endpoint must return a body containing success: true:

{ "success": true }

:::

Idempotency​

payment_success is sent once per booking — if a gateway re-delivers its own callback to us, you do not get a second webhook.

You can still receive a repeat from the retry schedule, though, most often when you processed the event successfully but your response never reached us. Every payload carries an event_id that stays identical across all retries of the same delivery. Store processed event_id values and ignore repeats.

  1. Read the raw body.
  2. Verify X-Webhook-Signature. Reject with 401 if it does not match.
  3. Parse the JSON. If type is not one you handle, return {"success": true} and stop.
  4. If event_id was already processed, return {"success": true} and stop.
  5. Mark the booking paid or failed on your side, record the event_id, return {"success": true}.

Return a non-2xx only when you want the event redelivered — for example when your own database was briefly unavailable.