Webhooks
Webhooks push events to your server as they happen in Hapana — a member signs up, a booking is made, someone checks in — so you don't have to poll the API. Each delivery is a signed HTTPS POST.
How it works
A webhook is an HTTPS endpoint you own, registered against your brand in Hapana Core. When a subscribed event happens at one of the locations the webhook covers, Hapana sends a POST with a JSON body describing the event. Every delivery is signed with a secret only you and Hapana know, so you can prove it came from Hapana before you act on it.
- Brand-level. Webhooks are configured once for the whole brand, not per location. Each webhook chooses which locations it hears from.
- Per-endpoint delivery. Each webhook gets its own delivery and its own retries, so one slow endpoint never delays another.
- After the fact. Events are sent once the change has been saved, so if you read the record back through the API straight away, you see the state the event announced.
Setting up a webhook in Core
Webhooks are managed by brand owners and admins — anyone whose role has the Manage integrations permission. Staff without it don't see the tab.
- In Hapana Core, open Control Center → Developer APIs → Webhooks.
- Click Add webhook and give it a name you'll recognise (for example, “Data warehouse sync”).
- Enter the Webhook URL. It must be a public
https://address; private and internal network addresses are refused. - Choose the events to subscribe to. See Webhook events for what each one sends.
- Choose the locations — see Choosing locations below.
- Optionally add a custom header (name and value) — see Custom header.
- Save. Hapana shows the webhook's signing secret (it starts with
whsec_). Copy it into your server's secret store now.
Choosing locations
Every webhook is scoped to a set of your brand's locations:
| Option | Behaviour |
|---|---|
| All locations | Receives events from every location in the brand, including locations added after the webhook was created. Use this for brand-wide integrations. |
| Selected locations only | Receives events only from the locations you tick. New locations are not added automatically — edit the webhook to include them. |
Every event body carries the siteId of the location it happened at, so one endpoint can serve many locations and still route each event correctly.
Custom header
If your endpoint sits behind a gateway that expects its own token, add one custom header. Hapana sends it with every delivery, alongside the signature headers. Fill in both the name and the value, or neither.
The header name must be a valid HTTP header name, and can't be Host, Content-Type, Content-Length, Authorization, or anything starting with X-Hapana-. A custom header is a convenience for your gateway — it does not replace signature verification.
What a delivery looks like
POST /hapana/webhooks HTTP/1.1
Host: example.com
Content-Type: application/json
X-Hapana-Event-ID: evt_7c1e1c52-3f0a-4a8e-9a43-2f5b1d0e6c11
X-Hapana-Event-Type: booking.created
X-Hapana-Timestamp: 1790982502
X-Hapana-Signature: t=1790982502,v1=48373c7bd723348cf7520b12718567bdd0950ea5f913dbc30177a0287c732c27
{
"id": "evt_7c1e1c52-3f0a-4a8e-9a43-2f5b1d0e6c11",
"object": "event",
"type": "booking.created",
"apiVersion": "2026-01-10",
"createdAt": "2026-10-02T23:08:22.481Z",
"livemode": true,
"data": {
"bookingId": "b3f1c2a4-5d6e-4f70-8a91-0b2c3d4e5f60",
"clientId": "5a8d2e61-0c4b-4f3a-9e7d-1b2c3d4e5f6a",
"sessionId": "9e0f1a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b",
"siteId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"status": "CONFIRMED",
"bookingSource": "CREDIT"
}
}The event envelope
Every event (except the legacy v1 check-in) arrives in the same envelope:
| Field | Type | Description |
|---|---|---|
id | string | Unique event id, evt_ + UUID. The same on every retry — use it for idempotency. |
object | string | Always "event". |
type | string | The event type, e.g. booking.created. |
apiVersion | string | Envelope version, currently 2026-01-10. |
createdAt | string | ISO 8601 UTC time the event was raised. |
livemode | boolean | Always true — every delivery describes real data. |
data | object | The event-specific payload. See Webhook events. |
Payloads are deliberately small: they carry ids and the fields that changed, not full records. If you need more, read the record back through the Business API. New fields may be added to data at any time, so ignore fields you don't recognise rather than rejecting the event.
Headers
| Header | Meaning |
|---|---|
Content-Type | Always application/json. |
X-Hapana-Event-ID | The event id (matches id in the body). Same across retries. |
X-Hapana-Event-Type | The event type (matches type in the body). |
X-Hapana-Timestamp | Unix epoch seconds when this attempt was signed. |
X-Hapana-Signature | t=<timestamp>,v1=<hex HMAC-SHA256>. See Verifying signatures. |
| Your custom header | Only if you configured one on the webhook. |
Responding, timeouts & retries
Respond with any 2xx status within 10 seconds to acknowledge the delivery. The response body is ignored. Anything else counts as a failed attempt:
- a non-
2xxstatus (including4xx); - no response within 10 seconds, or a connection or TLS error;
- a
3xxredirect — redirects are never followed, so register the final URL.
A failed attempt is retried with exponential backoff: each event is attempted up to 3 times — the original delivery, then about 1 minute later, then about 2 minutes after that. Every attempt is signed afresh, so X-Hapana-Timestamp is always current.
200. Do the slow work (API calls, emails, syncs) in a background job so you stay well inside the 10-second timeout.Webhook status
Hapana tracks consecutive events that could not be delivered after all retries (not individual attempts — one transient error that succeeds on retry doesn't count).
| Status | When | Deliveries |
|---|---|---|
| Enabled | Normal state. | Sent |
| Failing | 3 events in a row failed. | Still sent. The next successful delivery returns it to Enabled. |
| Disabled | 10 events in a row failed. | Stopped. Fix your endpoint, then click Re-enable on the webhook in Core. |
Events raised while a webhook is disabled are not queued and are not sent when it is re-enabled. Use the API to reconcile anything you missed.
Delivery history
Every attempt — event type, event id, endpoint, response status, attempt number, latency, and any error — is recorded. Open the webhook in Core to see its recent deliveries, or see the full history under Control Center → Developer APIs → Logging. This is the first place to look when an endpoint goes to Failing.
Idempotency & ordering
Delivery is at least once: a retry after a timeout can deliver an event your server already processed. Make your handler idempotent by recording each X-Hapana-Event-ID you process and skipping ones you've seen:
const eventId = req.get('X-Hapana-Event-ID')
if (await processedEvents.has(eventId)) {
return res.sendStatus(200) // already handled — acknowledge and move on
}
await processedEvents.add(eventId, { ttlSeconds: 7 * 24 * 3600 })
await queue.enqueue(event)
res.sendStatus(200)Events are not guaranteed to arrive in order — a retried booking.created can land after the booking.cancelled that followed it. Use createdAt to order events for the same record, or read the current state from the API when order matters.
Rotating the signing secret
Use Rotate secret on the webhook in Core. The new secret is shown once, and the old secret stops working immediately — deliveries fail signature checks until your endpoint has the new one. To rotate without dropping events, have your verifier temporarily accept either secret, rotate, deploy the new secret, then remove the old one.
Best practices
- Always verify the signature against the raw request body before parsing it. See Verifying signatures.
- Reject stale timestamps (older than 5 minutes) to block replays.
- Return 2xx quickly and process asynchronously.
- Deduplicate on the event id — retries reuse it.
- Subscribe only to what you use. Fewer events means less load on your endpoint.
- Treat payloads as notifications. Fetch authoritative state from the API when you need more than the event carries.
- Keep the secret secret. Store it in a secret manager, never in source control or client-side code.
- Watch for Failing. Alert on your own 5xx rate so you find out before Hapana disables the webhook.
Testing your endpoint
- Expose a local server over HTTPS with a tunnel (for example
ngrok http 3000orcloudflared tunnel), or use a request inspector such as webhook.site to see raw deliveries. - Create a webhook in Core pointing at that URL, scoped to a single test location under Selected locations only.
- Trigger a real event at that location — create a test member, book a class, or check someone in — and watch the delivery arrive.
- Confirm the attempt shows as delivered in the webhook's recent deliveries and in Developer APIs → Logging.
- Unit-test your verifier with the test vector before going live.
When you're done, point the webhook at your production URL (or delete it and create a new one with production scope).
checkIn webhook (for example, Realtime Feedback) can subscribe to the legacy v1 check-in event, which sends the exact v1 body. Questions? Email api-support@hapana.com.