Webhooks are how we push events to your systems. The full catalogue is 20 events covering the booking lifecycle, member state, billing, and platform-level configuration. Every delivery is signed with HMAC-SHA256, retried on failure with exponential backoff, and idempotent on its event_id.
If you've wired Stripe webhooks before, this is the same shape — we deliberately kept the contract familiar to reduce surprise.
Endpoint contract. Respond 2xx within 30 seconds and we consider the event delivered. Any non-2xx or a timeout puts the event into the retry queue.
Reference
Event catalogue
20 events · 4 groups · stable contract since v6.0
Subscribe to events in your dashboard under Settings → Webhooks → Events. Wildcards work — booking.* matches all six booking events.
| Event | Group | Description |
|---|---|---|
booking.created | Booking row inserted (pending_payment for paid resources). | |
booking.updated | Any field on the booking changed — start/end/notes/host. | |
booking.confirmed | Payment converged; booking is now hard-held. | |
booking.cancelled | Booking cancelled by member, operator, or expiry. | |
booking.checked_in | Visitor or member touched the kiosk for this booking. | |
booking.no_show | Auto-fired 15 min after start if no check-in. | |
member.created | Member invited or self-signed-up; status=invited. | |
member.activated | Member completed signup and accepted T&Cs. | |
member.updated | Member tier, email, or company changed. | |
member.deactivated | Member set inactive — soft-delete, audit-logged. | |
invoice.created | Invoice draft created and pushed to Xero. | |
invoice.paid | Stripe payment + Xero reconciliation both complete. | |
invoice.voided | Invoice marked void (and reflected in Xero, per KARO-221). | |
payment.succeeded | Stripe PaymentIntent transitioned to succeeded. | |
payment.failed | Per-attempt — never terminal; check booking.cancelled for finality. | |
payment.refunded | Stripe refund processed; Xero credit note auto-created. | |
subscription.created | Stripe Connect subscription started. | |
subscription.cancelled | Soft cancel — end_date set; status flips at cron. | |
platform.host_created | Operator workspace provisioned. | |
platform.settings_changed | High-value settings JSONB column changed; one per field. |
booking.createdBooking row inserted (pending_payment for paid resources).
booking.updatedAny field on the booking changed — start/end/notes/host.
booking.confirmedPayment converged; booking is now hard-held.
booking.cancelledBooking cancelled by member, operator, or expiry.
booking.checked_inVisitor or member touched the kiosk for this booking.
booking.no_showAuto-fired 15 min after start if no check-in.
member.createdMember invited or self-signed-up; status=invited.
member.activatedMember completed signup and accepted T&Cs.
member.updatedMember tier, email, or company changed.
member.deactivatedMember set inactive — soft-delete, audit-logged.
invoice.createdInvoice draft created and pushed to Xero.
invoice.paidStripe payment + Xero reconciliation both complete.
invoice.voidedInvoice marked void (and reflected in Xero, per KARO-221).
payment.succeededStripe PaymentIntent transitioned to succeeded.
payment.failedPer-attempt — never terminal; check booking.cancelled for finality.
payment.refundedStripe refund processed; Xero credit note auto-created.
subscription.createdStripe Connect subscription started.
subscription.cancelledSoft cancel — end_date set; status flips at cron.
platform.host_createdOperator workspace provisioned.
platform.settings_changedHigh-value settings JSONB column changed; one per field.
Payload shape
Every event payload follows the same envelope. data contains the resource snapshot at event time — never the diff. If you need diff semantics, key off created vs updated events.
{
"id": "evt_demo_3f8a2c1b9d4e7a6f",
"type": "booking.confirmed",
"created_at": "2026-05-22T10:03:14Z",
"livemode": false,
"data": {
"id": "bk_demo_71c3b6f4a8e2d9c5",
"resource_id": "res_boardroom_demo",
"member_id": "mem_demo_anya",
"start_at": "2026-05-23T10:00:00Z",
"duration_minutes": 60,
"status": "confirmed",
"payment_intent_id": "pi_demo_3f8a2c1b9d4e7a6f"
}
}Behavior
Delivery and retries
At-least-once · exponential backoff · 24 hour retry budget
We deliver every event at least once. The retry policy is exponential, starting at 5 seconds and doubling on each failure. We give up after 8 attempts (~24 hours total).
Retry schedule
- Attempt 1. Immediate.
- Attempt 2. 5 seconds after fail.
- Attempt 3. 30 seconds.
- Attempt 4. 2 minutes.
- Attempt 5. 15 minutes.
- Attempt 6. 1 hour.
- Attempt 7. 6 hours.
- Attempt 8. 24 hours after the original send.
What counts as a fail
- Any HTTP status ≥ 400.
- Connection refused, DNS failure, or TCP reset.
- No response body received within 30 seconds (we count the whole exchange, including TLS).
event.id when persisting — a retry must be a no-op once the original succeeded. The four-layer Stripe confirmation pipeline in LiteHQ uses this exact pattern (success page + platform webhook + connect webhook + scheduler self-heal).Security
Signature verification
HMAC-SHA256 · per-endpoint secret · constant-time compare
Every webhook request includes a LiteHQ-Signatureheader. The signature is a hex-encoded HMAC-SHA256 over the timestamp + a dot + the raw request body, signed with your endpoint's secret. The header looks like this:
LiteHQ-Signature: t=1747904594,v1=8a7f3c1b9d4e7a6f5b9c3e7d1b8f4a2c9b7f3c1a4e8d2b6f5a9c3e7d1b8f4a2cVerification recipe
- Split the header on
,to gett=…(timestamp, unix seconds) andv1=…(signature). - Compute
hmac_sha256(secret, "{t}.{raw_body}")and hex-encode it. - Compare your computation against
v1with a constant-time comparator. Never use==— timing leaks. - Reject any request where the timestamp is more than 5 minutes off the current time.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(req: Request, secret: string, rawBody: string): boolean {
const header = req.headers.get('LiteHQ-Signature') ?? ''
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=') as [string, string]),
)
const t = Number(parts.t)
const sig = parts.v1
if (!t || !sig) return false
// Reject events older than 5 minutes (replay defence)
if (Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex')
// Constant-time compare — never `===`
return timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(sig, 'hex'),
)
}import hmac, hashlib, time
def verify(headers: dict, secret: str, raw_body: bytes) -> bool:
header = headers.get('LiteHQ-Signature', '')
parts = dict(p.split('=', 1) for p in header.split(','))
t = int(parts.get('t', 0))
sig = parts.get('v1', '')
if not t or not sig:
return False
# Reject events older than 5 minutes (replay defence)
if abs(time.time() - t) > 300:
return False
payload = f'{t}.'.encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)Security
Replay attack prevention
Timestamp window + event_id deduplication
Even with valid signatures, a captured webhook can be replayed. We defend against this in two ways. You should implement both — they're cheap and orthogonal.
Layer 1: Timestamp window
- Every
LiteHQ-Signatureincludes thet=…unix timestamp inside the HMAC payload. - Reject any request where
|now - t| > 300 seconds. Attackers can't fudgetbecause it's part of the signed payload.
Layer 2: event_id deduplication
- Every event has a unique
idfield (evt_…). Persist these in a dedup table when you ack the event. - On every webhook, look up the
event.id. If it's already in your dedup table, return 200 immediately — that's the at-least-once contract working as intended. - TTL the dedup table at 7 days (longer than our retry budget). Keeps the table small.
-- One row per processed event_id; drop rows older than 7 days
CREATE TABLE webhook_event_ids (
event_id text PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now()
);
-- In your handler, BEFORE any other work:
INSERT INTO webhook_event_ids (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- If the INSERT returns zero rows, you've seen this event already.
-- Return 200 and stop.Development
Local development
ngrok / webhook.site / loopback tunnel options
Receiving webhooks on localhostrequires a public-facing tunnel. Three approaches we recommend, depending on what you're building.
Option 1: ngrok (most common)
brew install ngrok && ngrok config add-authtoken <YOUR_TOKEN>.ngrok http 3000exposes your local app at a stable URL likehttps://demo-abc-123.ngrok-free.app.- Register that URL in your LiteHQ dashboard under Settings → Webhooks → New endpoint.
Option 2: webhook.site (debugging only)
Visit https://webhook.site, copy your unique URL, register it as a temporary webhook endpoint. Useful for inspecting payload shape without writing any code. Don't use this in production — they retain payloads, including any sensitive data.
Option 3: cloudflared (more stable)
brew install cloudflared.cloudflared tunnel --url http://localhost:3000. Free, no auth needed for one-off tunnels, no session limit.
stripe listen --forward-to localhost:3000 works the same way and is a good pattern to imitate. We considered shipping a similar litehq listen CLI command — vote in the roadmap if you'd use it.