Skip to content

Documentation · API

Webhooks

9 min read · API · Updated 2026-05-17

API9 minShare
Documentation sections

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.

  • booking.createdBooking

    Booking row inserted (pending_payment for paid resources).

  • booking.updatedBooking

    Any field on the booking changed — start/end/notes/host.

  • booking.confirmedBooking

    Payment converged; booking is now hard-held.

  • booking.cancelledBooking

    Booking cancelled by member, operator, or expiry.

  • booking.checked_inBooking

    Visitor or member touched the kiosk for this booking.

  • booking.no_showBooking

    Auto-fired 15 min after start if no check-in.

  • member.createdMember

    Member invited or self-signed-up; status=invited.

  • member.activatedMember

    Member completed signup and accepted T&Cs.

  • member.updatedMember

    Member tier, email, or company changed.

  • member.deactivatedMember

    Member set inactive — soft-delete, audit-logged.

  • invoice.createdBilling

    Invoice draft created and pushed to Xero.

  • invoice.paidBilling

    Stripe payment + Xero reconciliation both complete.

  • invoice.voidedBilling

    Invoice marked void (and reflected in Xero, per KARO-221).

  • payment.succeededBilling

    Stripe PaymentIntent transitioned to succeeded.

  • payment.failedBilling

    Per-attempt — never terminal; check booking.cancelled for finality.

  • payment.refundedBilling

    Stripe refund processed; Xero credit note auto-created.

  • subscription.createdBilling

    Stripe Connect subscription started.

  • subscription.cancelledBilling

    Soft cancel — end_date set; status flips at cron.

  • platform.host_createdPlatform

    Operator workspace provisioned.

  • platform.settings_changedPlatform

    High-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.

example_payload.json
json
{
  "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).

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:

signature_header.example
text
LiteHQ-Signature: t=1747904594,v1=8a7f3c1b9d4e7a6f5b9c3e7d1b8f4a2c9b7f3c1a4e8d2b6f5a9c3e7d1b8f4a2c

Verification recipe

  1. Split the header on , to get t=… (timestamp, unix seconds) and v1=… (signature).
  2. Compute hmac_sha256(secret, "{t}.{raw_body}") and hex-encode it.
  3. Compare your computation against v1 with a constant-time comparator. Never use == — timing leaks.
  4. Reject any request where the timestamp is more than 5 minutes off the current time.
verify_webhook.node.ts
typescript
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'),
  )
}
verify_webhook.python.py
python
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

  1. Every LiteHQ-Signature includes the t=… unix timestamp inside the HMAC payload.
  2. Reject any request where |now - t| > 300 seconds. Attackers can't fudge tbecause it's part of the signed payload.

Layer 2: event_id deduplication

  1. Every event has a unique id field (evt_…). Persist these in a dedup table when you ack the event.
  2. 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.
  3. TTL the dedup table at 7 days (longer than our retry budget). Keeps the table small.
event_dedup.sql
sql
-- 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)

  1. brew install ngrok && ngrok config add-authtoken <YOUR_TOKEN>.
  2. ngrok http 3000 exposes your local app at a stable URL like https://demo-abc-123.ngrok-free.app.
  3. 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)

  1. brew install cloudflared.
  2. cloudflared tunnel --url http://localhost:3000. Free, no auth needed for one-off tunnels, no session limit.
The webhook endpoint settings page — register a tunnel URL, choose events, and tap Save.
The webhook endpoint settings page — register a tunnel URL, choose events, and tap Save.