Webhooks

The platform fires webhook deliveries on every state change you care about: a client is created, a user is invited, a tenant grants OAuth consent. Webhooks are the async half of the API surface — the things you'd otherwise poll for.

Anatomy of a delivery

Every webhook request looks like this:

POST /your/handler/url HTTP/1.1
Host: yourapp.example.com
Content-Type: application/json
X-Five-Minutes-Event: client.created
X-Five-Minutes-Event-Id: evt_01HY...
X-Five-Minutes-Signature: t=1718000000,v1=2c4d...
X-Five-Minutes-Delivery-Id: del_01HY...

{
  "id": "evt_01HY...",
  "object": "event",
  "type": "client.created",
  "created": "2026-06-12T18:34:00.000Z",
  "tenant_id": "tnt_01HW...",
  "data": { "object": "client", "id": "cli_...", ... }
}

Four header invariants:

  • X-Five-Minutes-Event — the event type, redundant with data.type so you don't need to parse the body to route.
  • X-Five-Minutes-Event-Id — stable per event. Idempotency key. Same evt_* may be delivered more than once; dedupe on this id.
  • X-Five-Minutes-Signature — HMAC-SHA256 signature, see signature verification below.
  • X-Five-Minutes-Delivery-Id — unique per delivery attempt; useful for log correlation when the same event is retried.

The 21 events

Every event fires when its named state change commits in the platform DB. Deliveries are eventually consistent; the API call that triggered them returns first.

| Event | When | | --- | --- | | client.created | A new client row is inserted. | | client.updated | Any PATCH against a client (name, status, slug, domain). | | client.deleted | A client is soft-deleted. | | group.created | A new group row is inserted. | | group.updated | A group is renamed or its membership policy changes. | | group.member_added | A client is added to a group. | | group.member_removed | A client is removed from a group. | | group.owner_invited | A group owner invitation is sent. | | group.owner_removed | A group owner is removed. | | group.deleted | A group is hard-deleted. | | user.invited | A user invitation is sent. | | user.activated | An invited user accepts and the account becomes active. | | user.deactivated | A user is suspended (kept for audit, not deletable). | | user.role_changed | A user's role assignment changes within the tenant. | | tenant.suspended | The tenant is suspended (SA action; access frozen). | | tenant.reactivated | A suspended tenant is reactivated. | | location.created | New location nested under a client or group. | | location.updated | Address, timezone, country, or default-flag change. | | location.deleted | A non-default location is removed (three-guard pass). | | oauth.app_authorized | A tenant grants consent for an OAuth app. | | oauth.app_revoked | A tenant revokes consent for an OAuth app. |

Subscribe to events at /webhooks in the console. Each endpoint declares the events it wants; we won't fire anything you didn't subscribe to.

Sample payloads

client.created:

{
  "id": "evt_01HY01PR...",
  "object": "event",
  "type": "client.created",
  "created": "2026-06-12T18:34:00.000Z",
  "tenant_id": "tnt_01HW...",
  "data": {
    "object": "client",
    "id": "cli_01HY...",
    "name": "Acme Corp",
    "status": "active",
    "slug": "acme-corp",
    "custom_domain": null,
    "created_at": "2026-06-12T18:34:00.000Z",
    "updated_at": "2026-06-12T18:34:00.000Z"
  }
}

user.role_changed:

{
  "id": "evt_01HY...",
  "object": "event",
  "type": "user.role_changed",
  "created": "2026-06-12T18:34:00.000Z",
  "tenant_id": "tnt_01HW...",
  "data": {
    "object": "user",
    "id": "usr_01HY...",
    "email": "alex@acme.example",
    "previous_role": "member",
    "new_role": "owner",
    "changed_by": "usr_01HY...",
    "changed_at": "2026-06-12T18:34:00.000Z"
  }
}

Signature verification

Each delivery carries an HMAC-SHA256 signature in X-Five-Minutes-Signature. Always verify it before trusting the body.

The header value is t=<unix-ts>,v1=<hex-digest>. Compute the digest as HMAC-SHA256(signing_secret, t + "." + raw_body). Reject if t is more than 5 minutes off the current time (replay defence).

Node

import { createHmac } from 'node:crypto'

function verify(headerValue, rawBody, secret) {
  const parts = Object.fromEntries(
    headerValue.split(',').map((p) => p.split('=')),
  )
  const ts = parts.t
  const sig = parts.v1
  if (!ts || !sig) return false
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const expected = createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex')
  return timingSafeEqualHex(expected, sig)
}

Python

import hmac, hashlib, time

def verify(header_value: str, raw_body: bytes, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in header_value.split(','))
    ts = parts.get('t'); sig = parts.get('v1')
    if not ts or not sig: return False
    if abs(time.time() - int(ts)) > 300: return False
    expected = hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, sig)

Ruby

require 'openssl'

def verify(header_value, raw_body, secret)
  parts = header_value.split(',').map { |p| p.split('=', 2) }.to_h
  ts, sig = parts['t'], parts['v1']
  return false unless ts && sig
  return false if (Time.now.to_i - ts.to_i).abs > 300
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{raw_body}")
  OpenSSL.fixed_length_secure_compare(expected, sig)
end

Hash the raw request body — JSON-parse it AFTER you verify. Re-serialising will change byte ordering and fail the check.

Retry schedule

If your handler responds with a non-2xx status (or fails to respond within 10 seconds), we retry with exponential backoff:

| Attempt | Delay after previous | | --- | --- | | 1 | (initial delivery) | | 2 | 30 seconds | | 3 | 2 minutes | | 4 | 10 minutes | | 5 | 1 hour | | 6 | 6 hours | | 7 | 24 hours |

After attempt 7, we mark the delivery failed and stop. Find it under /webhooks → "Deliveries" → status: failed. Replay manually from there once your handler is fixed.

Idempotency

The platform never suppresses a duplicate; you'll see the same evt_* id more than once if a network blip causes us to redeliver. The right pattern in your handler:

async function handle(req, res) {
  const eventId = req.headers['x-five-minutes-event-id']
  if (await alreadyProcessed(eventId)) {
    return res.status(200).end('dup')
  }
  await processInTransaction(eventId, req.body)
  res.status(200).end('ok')
}

A UNIQUE index on (event_id) in your processed-events table makes this trivially correct.

Order of delivery

Events for a tenant are delivered in roughly the order they were committed, but not strictly. If you need exact ordering, sort by the created timestamp inside the body — and reconcile on evt_* id when timestamps collide.