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 withdata.typeso you don't need to parse the body to route.X-Five-Minutes-Event-Id— stable per event. Idempotency key. Sameevt_*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.