# Webhooks

> Signed, retried event delivery to your own https endpoint — the envelope, the event types, verifying the signature and the retry schedule.



A webhook endpoint is an https URL of yours that Awrora POSTs events to. Create one in Awrora under <Path>Settings → Apps → For developers</Path> with **New webhook**, or with [`POST /v1/webhooks`](/api-reference/webhooks/create-webhook) and a key with `webhooks:manage`, and pick which event types it receives. An organisation can have at most **10 endpoints**.

Every delivery is signed, retried on failure, and recorded in a delivery log you can read back with [`GET /v1/webhooks/{id}/deliveries`](/api-reference/webhooks/list-webhook-deliveries).

## The envelope

Every delivery has the same body shape, whatever the event type:

```json
{
  "id": "9b2f4c1e-…",
  "type": "booking.created",
  "created_at": "2026-09-11T20:00:00+02:00",
  "api_version": "2026-09-09",
  "data": {
    "object": { "…": "the resource" }
  }
}
```

| Field         | Meaning                                                                                                                                 |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | The event's id. **Stable across retries** — use it to deduplicate.                                                                      |
| `type`        | The event type. See the table below.                                                                                                    |
| `created_at`  | When it happened, ISO 8601 with your organisation's offset.                                                                             |
| `api_version` | Which version of the resource shapes the payload follows. Same string as `info.version` in the OpenAPI document.                        |
| `data.object` | The resource, in **exactly** the shape the REST API returns it. A `booking.created` carries the same object as `GET /v1/bookings/{id}`. |

Two deliberate exceptions: gift cards are always the list form (`code_last4`, never `code`), and bookings carry `manage_url: null`. Both are secrets that do not belong in receivers' logs — fetch the resource when you need them.

The same envelope is what [`GET /v1/events`](/api-reference/events/list-events) returns, so you can fetch an event by the id in the `Awrora-Event-Id` header with [`GET /v1/events/{id}`](/api-reference/events/get-event) and compare.

## Headers on every delivery

| Header               | Value                                                                               |
| -------------------- | ----------------------------------------------------------------------------------- |
| `Content-Type`       | `application/json`                                                                  |
| `Awrora-Signature`   | `t=<unix seconds>,v1=<hex hmac-sha256>` — see below                                 |
| `Awrora-Event-Id`    | The event id. Same as `id` in the body.                                             |
| `Awrora-Event-Type`  | The event type. Same as `type` in the body.                                         |
| `Awrora-Delivery-Id` | This delivery attempt's id. **Changes** between retries — do not deduplicate on it. |
| `User-Agent`         | `Awrora-Webhooks/1.0`                                                               |

## Event types

| Type                  | When                                                                                                                                                        | Extra fields in `data`                                                           |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `booking.created`     | A booking was created, from any source: the storefront checkout, the admin calendar, `POST /v1/bookings`, or an AI agent. Exactly once per booking.         | —                                                                                |
| `booking.confirmed`   | A booking became — or was created as — `confirmed`. A card booking gets `created` then `confirmed`; so does a `pay_on_site` or `external_paid` API booking. | —                                                                                |
| `booking.cancelled`   | A booking was cancelled.                                                                                                                                    | `reason`: `admin`, `customer`, `min_participants`, `abandoned`, `api` or `agent` |
| `booking.rescheduled` | A booking was moved to another departure.                                                                                                                   | `previous_departure`: `{ id, starts_at }`                                        |
| `customer.created`    | A customer record was created. Not fired when an existing customer is reused.                                                                               | —                                                                                |
| `customer.updated`    | A customer's contact details changed.                                                                                                                       | —                                                                                |
| `gift_card.issued`    | A gift card was paid for and issued.                                                                                                                        | —                                                                                |
| `gift_card.redeemed`  | A gift card was used to pay for a booking. `object` is the card **after** the redemption.                                                                   | `redeemed_amount`: `{ amount_minor, currency }`, `booking_id`                    |

Extra fields sit next to `object` and carry what you cannot read out of the resource afterwards:

```json
{
  "id": "…", "type": "booking.cancelled", "created_at": "…", "api_version": "2026-09-09",
  "data": { "object": { "…": "the booking, now cancelled" }, "reason": "customer" }
}
```

`ping` is sent only by [`POST /v1/webhooks/{id}/test`](/api-reference/webhooks/test-webhook). You cannot subscribe to it — the test is delivered to that one endpoint regardless of its subscriptions, so you can verify the transport before deciding what to listen for. Its `object` is `{ "message": "Awrora webhook test", "endpoint_id": "…" }`.

## Verifying the signature

```http
Awrora-Signature: t=1789000000,v1=4997331643790e5cb6674c24b5819f708478ba10ee024736592e6d65cc949a60
```

`v1` is `HMAC-SHA256(secret, "<t>.<raw body>")` as lowercase hex, where `t` is the unix timestamp in the same header. The secret is the `whsec_…` string returned **once** when you created the endpoint or rotated its secret.

* **Verify against the raw body, as text.** Do not parse and re-serialise the JSON first — key order, unicode escaping and number formatting all change, and your check will fail intermittently. Read the bytes, verify, *then* parse.
* **Compare in constant time** (`crypto.timingSafeEqual`, `hmac.compare_digest`). A plain `===` leaks how many leading characters a guess got right.
* **Reject timestamps outside a tolerance window** — we recommend **5 minutes**, in both directions. The timestamp is part of the signed material, so it cannot be changed without breaking the signature.
* **Accept any matching `v1`.** The header may carry more than one `v1=` value; ignore elements you do not recognise.

<Tabs items="['Node', 'Python']">
  <Tab value="Node">
    ```js
    const { createHmac, timingSafeEqual } = require('node:crypto')

    function verifyAwroraSignature(header, body, secret, toleranceSeconds = 300) {
      let t = null
      const signatures = []
      for (const part of String(header || '').split(',')) {
        const i = part.indexOf('=')
        if (i <= 0) continue
        const name = part.slice(0, i).trim()
        const value = part.slice(i + 1).trim()
        if (name === 't' && Number(value) > 0) t = Math.floor(Number(value))
        else if (name === 'v1' && value) signatures.push(value)
      }
      if (t === null || signatures.length === 0) return false
      if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false

      // body must be the raw request body as a string, not a re-serialised object
      const expected = Buffer.from(
        createHmac('sha256', secret).update(`${t}.${body}`).digest('hex'),
        'utf8'
      )
      return signatures.some((sig) => {
        const candidate = Buffer.from(sig, 'utf8')
        return candidate.length === expected.length && timingSafeEqual(candidate, expected)
      })
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import hashlib, hmac, time

    def verify_awrora_signature(header, body, secret, tolerance_seconds=300):
        """body must be the raw request body as bytes or str, not a re-serialised dict."""
        t, signatures = None, []
        for part in (header or "").split(","):
            name, sep, value = part.partition("=")
            name, value = name.strip(), value.strip()
            if not sep or not name:
                continue
            if name == "t":
                try:
                    if float(value) > 0:
                        t = int(float(value))
                except ValueError:
                    pass
            elif name == "v1" and value:
                signatures.append(value)
        if t is None or not signatures:
            return False
        if abs(int(time.time()) - t) > tolerance_seconds:
            return False
        if isinstance(body, bytes):
            body = body.decode("utf-8")
        expected = hmac.new(
            secret.encode("utf-8"), f"{t}.{body}".encode("utf-8"), hashlib.sha256
        ).hexdigest()
        return any(hmac.compare_digest(expected, sig) for sig in signatures)
    ```
  </Tab>
</Tabs>

### A known-good vector

Check your implementation against this before pointing it at us:

* **secret** `whsec_2f5b8c1d9e4a7b3c6d0f8a2e5b7c9d1f`

* **t** `1789000000`

* **body** (exactly, no trailing newline):

  ```json
  {"id":"11111111-1111-4111-8111-111111111111","type":"ping","created_at":"2026-09-09T12:00:00+02:00","api_version":"2026-09-09","data":{"object":{"message":"Awrora webhook test","endpoint_id":"ep_1"}}}
  ```

* **v1** `b5ce26987e8b3c0d2586801c17a4dca3cb889b93d1bb15bd50ae083078507e4e`

The tolerance check will reject this timestamp as too old — pass a large tolerance, or compare the hex directly, when testing against the vector.

## Retries

A delivery succeeds on **2xx**. Anything else is a failed attempt — including 3xx, because redirects are never followed, and a timeout: we wait **10 seconds** for your response.

| Attempt | Waits before the next one    |
| ------- | ---------------------------- |
| 1       | 1 minute                     |
| 2       | 5 minutes                    |
| 3       | 30 minutes                   |
| 4       | 2 hours                      |
| 5       | 8 hours                      |
| 6       | — the delivery is now `dead` |

That is about 10½ hours of trying. A `dead` delivery stays in the log; you can queue it for one more attempt with [`POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry`](/api-reference/webhooks/retry-webhook-delivery). Delivery logs are kept for 30 days.

### Auto-disabling

If an endpoint has had **no successful delivery for 72 hours** *and* at least six consecutive failures, Awrora sets its status to `auto_disabled` and stops sending. Its queued deliveries are marked `failed` with `endpoint_disabled`.

To resume, fix your receiver, then [update the endpoint](/api-reference/webhooks/update-webhook) with `{"status": "enabled"}`. That also resets the failure counter.

## Best practice

* **Answer 2xx fast, and do the work afterwards.** Write the event to your own queue and return. If you take longer than 10 seconds, we time out and retry — and you do the work twice.
* **Deduplicate on `id`.** Delivery is at-least-once; a retry arrives with the same event `id` but a new `Awrora-Delivery-Id`.
* **Do not assume ordering.** Events for one booking are emitted in order, but a retry can put a later event ahead of an earlier one. `created_at` tells you the real order.
* **Handle unknown types.** We add event types over time. Ignore what you do not recognise — a `500` on an unknown type becomes a retry loop and eventually auto-disables your endpoint.
* **Store the secret, do not log it.** If you lose it, [rotate it](/api-reference/webhooks/rotate-webhook-secret). The old one stops working immediately; deliveries in flight fail and retry until your receiver has the new one.
* **Catch up with events.** After downtime, walk [`GET /v1/events`](/api-reference/events/list-events) by `starting_after` instead of reconciling your whole database. Events are kept for 90 days.
