Webhooks let your backend react asynchronously instead of polling. You register
an HTTPS endpoint; Safariat POSTs a signed JSON event to it.
Event catalogue
| Event type | Emitted when |
|---|
booking.confirmed | A POST /bookings succeeded. |
booking.cancelled | A booking was cancelled (by you or by Safariat). |
booking.completed | The travel date passed and the booking is fulfilled. |
settlement.issued | A monthly settlement moved to ISSUED. |
settlement.paid | A settlement was marked PAID (funds reconciled). |
adventure.unavailable | A previously bookable adventure/slot is no longer sellable. |
Event envelope
{
"id": "8c3f9a40-3b5d-4d52-9a2e-cc1f4b7d8e2a",
"type": "booking.confirmed",
"created_at": "2026-07-12T10:05:00Z",
"test": false,
"data": { "booking_number": "MV-AB7X92", "partner_reference": "YBANK-BOOK-001" }
}
id — idempotent event ID. Deduplicate on it (the same event may be delivered more than once).
test — true when the event comes from an sk_test_* key or from
POST /webhooks/{id}/test. Your production handler must ignore or clearly label these.
| Header | Purpose |
|---|
X-SafarApi-Signature | HMAC-SHA256 of the raw body with the endpoint’s signing_secret. |
X-SafarApi-Event-Id | Same UUID as id — use for deduplication. |
X-SafarApi-Delivery-Id | Unique per delivery attempt. The same Event-Id can arrive under several Delivery-Ids (retries / re-deliveries). |
X-SafarApi-Event-Type | The event type, for routing before parsing. |
The signing_secret is returned once, in the POST /webhooks response
(WebhookEndpointWithSecret). It is never displayable again — store it securely.
Verifying the signature
Compare in constant time. Reject on mismatch.
import crypto from 'node:crypto';
function verify(rawBody, header, signingSecret) {
const expected = crypto.createHmac('sha256', signingSecret)
.update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
import hmac, hashlib
def verify(raw_body: bytes, header: str, signing_secret: str) -> bool:
expected = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Verify against the exact bytes received, before any JSON re-serialization.
Reformatting the body breaks the signature.
Retries
A delivery is successful on a 2xx response within the timeout. Otherwise
Safariat retries up to 5 attempts with backoff: 5 min → 15 min → 1 h →
6 h → 24 h. After the last attempt the delivery is dead-lettered. Because
retries happen, your handler must be idempotent on X-SafarApi-Event-Id.
Respond 2xx immediately and process asynchronously — slow handlers cause
timeouts and unnecessary retries.
Delivery semantics
SafarAPI guarantees at-least-once delivery, not exactly-once. Two rules follow:
- Deduplicate. The same event (
X-SafarApi-Event-Id) may be delivered more
than once — on retry, and in a narrow window if a delivery is received but our
status write is interrupted. Treat the first successful processing as
authoritative and make re-processing a no-op (upsert / dedup table keyed by
Event-Id).
- Do not rely on ordering. Events are dispatched concurrently and retried
with backoff, so they can arrive out of order (e.g.
booking.cancelled
before its booking.confirmed, or a retried event after a newer one). Drive
your state from the event’s created_at and your own state machine — never
assume the arrival order reflects the real sequence.
Missed or dead-lettered a delivery? List and inspect attempts with
GET /webhooks/deliveries, and re-drive one with
POST /webhooks/deliveries/{id}/replay. You can also reconcile from the
authoritative REST resources (GET /bookings/{n}, GET /settlements) at any time —
webhooks are an optimization over polling, not the source of truth.
Testing
POST /webhooks/{id}/test sends a synthetic event to your endpoint and returns
the HTTP status and response time it observed — use it to validate connectivity
and your signature check before going live.