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 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" }
  }
}
FieldMeaning
idThe event's id. Stable across retries — use it to deduplicate.
typeThe event type. See the table below.
created_atWhen it happened, ISO 8601 with your organisation's offset.
api_versionWhich version of the resource shapes the payload follows. Same string as info.version in the OpenAPI document.
data.objectThe 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#

HeaderValue
Content-Typeapplication/json
Awrora-Signaturet=<unix seconds>,v1=<hex hmac-sha256> — see below
Awrora-Event-IdThe event id. Same as id in the body.
Awrora-Event-TypeThe event type. Same as type in the body.
Awrora-Delivery-IdThis delivery attempt's id. Changes between retries — do not deduplicate on it.
User-AgentAwrora-Webhooks/1.0

Event types#

TypeWhenExtra fields in data
booking.createdA booking was created, from any source: the storefront checkout, the admin calendar, POST /v1/bookings, or an AI agent. Exactly once per booking.—
booking.confirmedA 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.cancelledA booking was cancelled.reason: admin, customer, min_participants, abandoned, api or agent
booking.rescheduledA booking was moved to another departure.previous_departure: { id, starts_at }
customer.createdA customer record was created. Not fired when an existing customer is reused.—
customer.updatedA customer's contact details changed.—
gift_card.issuedA gift card was paid for and issued.—
gift_card.redeemedA 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=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.

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.

AttemptWaits before the next one
11 minute
25 minutes
330 minutes
42 hours
58 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 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. 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 by starting_after instead of reconciling your whole database. Events are kept for 90 days.