A webhook endpoint is an https URL of yours that Awrora POSTs events to. Create one in Awrora under SettingsAppsFor developers with New webhook, or with POST /v1/webhooks 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.
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 returns, so you can fetch an event by the id in the Awrora-Event-Id header with GET /v1/events/{id} 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. 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=4997331643790e5cb6674c24b5819f708478ba10ee024736592e6d65cc949a60v1 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 onev1=value; ignore elements you do not recognise.
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)
})
}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. 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 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 eventidbut a newAwrora-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_attells you the real order. - Handle unknown types. We add event types over time. Ignore what you do not recognise — a
500on 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. 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/eventsbystarting_afterinstead of reconciling your whole database. Events are kept for 90 days.