Skip to main content
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 typeEmitted when
booking.confirmedA POST /bookings succeeded.
booking.cancelledA booking was cancelled (by you or by Safariat).
booking.completedThe travel date passed and the booking is fulfilled.
settlement.issuedA monthly settlement moved to ISSUED.
settlement.paidA settlement was marked PAID (funds reconciled).
adventure.unavailableA 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).
  • testtrue when the event comes from an sk_test_* key or from POST /webhooks/{id}/test. Your production handler must ignore or clearly label these.

Delivery headers

HeaderPurpose
X-SafarApi-SignatureHMAC-SHA256 of the raw body with the endpoint’s signing_secret.
X-SafarApi-Event-IdSame UUID as id — use for deduplication.
X-SafarApi-Delivery-IdUnique per delivery attempt. The same Event-Id can arrive under several Delivery-Ids (retries / re-deliveries).
X-SafarApi-Event-TypeThe 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.