# Webhooks

Safra POSTs a signed JSON payload to your HTTPS URL when bookings, trips, schedules, or payments change. Verify the signature before you trust the body.

## Create an endpoint

POST /webhooks with a URL and at least one event. The response includes secret once (whsec_…). Store it like an API key.

**Create**

```
curl -X POST https://safraway.com/api/partner/v1/webhooks \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://crm.example.com/safra/webhooks","events":["booking.created","booking.updated"]}'
```

## Delivery headers

| Header | Purpose |
| --- | --- |
| Content-Type | application/json |
| User-Agent | Safra-Partner-Webhooks/1.0 |
| X-Safra-Event | Event name, e.g. booking.approved |
| X-Safra-Delivery | Event id (evt_…) |
| X-Safra-Signature | sha256= hex HMAC of the raw body |

## Verify the signature

Compute HMAC-SHA256 of the exact raw request body with your signing secret. Prefix with sha256=. Compare using a constant-time function.

**Node**

```
const crypto = require("crypto");
const expected = "sha256=" + crypto
  .createHmac("sha256", secret)
  .update(rawBody)
  .digest("hex");
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
```

**PHP**

```
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);
hash_equals($expected, $header);
```

**OpenSSL**

```
SECRET=whsec_…
BODY='{"event":"webhook.test"}'
sig=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
# X-Safra-Signature: sha256=$sig
```

## Envelope

**JSON**

```
{
  "id": "evt_01j…",
  "event": "booking.created",
  "apiVersion": "2026-08-21",
  "occurredAt": "2026-08-21T12:00:00Z",
  "data": {
    "id": "booking-uuid",
    "tripId": "trip-uuid",
    "status": "requested",
    "seats": 1,
    "source": "partner_api",
    "boardedAt": null,
    "noShow": false
  }
}
```

## Events

| Event | When |
| --- | --- |
| booking.created | New seat request (app or Partner API) |
| booking.updated | Booking fields changed (including admin edits) |
| booking.approved / rejected / cancelled | Status transitions |
| booking.boarded / booking.no_show | Boarding outcomes |
| trip.created / updated / cancelled / completed | Trip lifecycle |
| schedule.created / updated / deleted | Recurring templates |
| payment.updated | Payment on a company booking changed |
| webhook.test | Dashboard or POST …/test ping |

## Retries

- Deliveries are queued jobs. Timeout is 8 seconds.
- HTTP 2xx is success. Anything else retries.
- Up to 5 attempts with backoff 10s, 30s, 120s, 300s.
- Return 2xx quickly; do heavy CRM work after you ack.

> **Raw body:** Hash the bytes you received, not a re-serialized JSON object. Pretty-printing or key reordering will fail verification.
