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
GENERALURL 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
type | Fires when |
|---|---|
payment_success | The payment settled and the booking is marked paid. |
payment_failed | The 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
}
}
| Field | Type | Description |
|---|---|---|
type | String | payment_success or payment_failed. |
event_id | String | Unique per event, identical across retries. Use for deduplication. |
occurred_at | String | ISO 8601 with offset. When the payment settled, not when it was delivered. |
user_id | String | null | Hashed customer ID. null for a booking made without a customer account. |
booking_number | String | The booking this payment belongs to. |
payment_ref | String | The invoice_no used with the gateway. |
payment_status | String | Booking payment status at the time of the event, e.g. paid, rejected. |
grand_total | Number | Booking total, always in THB. |
grand_total_currency | String | Always THB. |
payment_record.grand_total | Integer | The amount actually charged, in the currency the customer paid in. |
payment_record.payment_currency | String | null | Currency actually charged. null when charged in THB. |
payment_record.payment_exchange_rate | Number | THB → 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
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | |
X-Webhook-Event | payment_success | Same value as type. Lets you route before parsing. |
X-Webhook-Event-Id | pwh_65zr6qkrmzmeoppe | Same value as event_id. |
X-Webhook-Signature | sha256=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
| Timeout | 15 seconds |
| Success | HTTP 2xx and a JSON body containing "success": true |
| Retries | Every 5 minutes for 30 minutes (about 6 attempts) |
| After that | Logged 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.
Recommended handler
- Read the raw body.
- Verify
X-Webhook-Signature. Reject with401if it does not match. - Parse the JSON. If
typeis not one you handle, return{"success": true}and stop. - If
event_idwas already processed, return{"success": true}and stop. - 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.
Related
- Payment Guideline — the full booking-to-payment flow.
- Payment Request — issuing the payment instruction.
- Flight Webhooks — the flight booking lifecycle events, delivered to the same endpoint.