Webhooks
DocumentationRekey POSTs a signed JSON event to your backend whenever something happens in an Application: sign-ups, MFA changes, payments, subscription transitions, dunning, devices, licences and credits. Every delivery carries an HMAC-SHA256 signature so you can prove it came from Rekey.
Register an endpoint
- Operator panel → Applications → pick one → Developer → Webhooks → Add endpoint.
- Paste your HTTPS URL and subscribe to all events (recommended) or a specific subset.
- Copy the signing secret shown once on creation, store it as an env var. It's the key you verify signatures with, and it's never displayed again (rotate from the endpoint detail page if lost).
The event envelope
Every delivery has the same top-level shape. The per-event payload lives under data. Treat eventId as your idempotency key, retries reuse the same id, so deduping is one cheap upsert.
{
"eventId": "clx9f2a0b0000abcd1234efgh",
"occurredAt": "2026-06-30T09:14:22.084Z",
"type": "subscription.activated",
"applicationId": "cmpsrrrsy0004ml1v174fsf05",
"data": {
"subscription": {
"id": "sub_...",
"status": "ACTIVE",
"endUserId": "usr_...",
"planSlug": "pro-monthly"
}
}
}Delivery headers
The event id and type are mirrored into headers so you can route or dedupe without parsing the body first.
POST /api/rekey/webhook HTTP/1.1 Content-Type: application/json User-Agent: rekey-webhooks/1.0 X-Rekey-Event-Id: clx9f2a0b0000abcd1234efgh X-Rekey-Event-Type: subscription.activated X-Rekey-Signature: t=1751274862,v1=3f8a1c…(64 hex chars)
Verify the signature
The X-Rekey-Signature header is t=<unix-ts>,v1=<hex>. The signed input is `${t}.${rawBody}` hash it with your endpoint secret and compare in constant time. Verify against the raw request body, before any JSON parsing or re-serialisation. Reject deliveries whose timestamp is more than ~5 minutes old to block replays.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret = the per-endpoint signing secret shown once when you
// created the endpoint (store it as an env var).
export function verifyRekeyWebhook(opts: {
rawBody: string; // the EXACT bytes received, verify before JSON.parse
header: string; // req.headers["x-rekey-signature"]
secret: string;
toleranceSeconds?: number; // default 300 (5 minutes)
}): boolean {
const tolerance = opts.toleranceSeconds ?? 300;
const now = Math.floor(Date.now() / 1000);
// Parse "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(
opts.header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number.parseInt(parts.t ?? "", 10);
const presented = parts.v1 ?? "";
if (!Number.isFinite(t) || !presented) return false;
// Reject replays outside the tolerance window.
if (Math.abs(now - t) > tolerance) return false;
// The signed input is `${t}.${rawBody}`, a different timestamp
// produces a different signature, so a replay can't be re-stamped.
const expected = createHmac("sha256", opts.secret)
.update(`${t}.${opts.rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(presented, "hex");
// Constant-time compare, never use === on the hex strings.
return a.length === b.length && timingSafeEqual(a, b);
}Event catalog
Billing and dunning events fire only when local state actually transitions, a provider replay that changes nothing emits nothing. A provider retry after a 5xx on our side may still re-emit, so always dedupe on eventId.
| Event | Fires when |
|---|---|
user.created | An end-user account is created (any sign-up path). |
user.updated | Profile, email, or metadata changes. |
user.onboarding_completed | Onboarding was marked complete after every required profile field was answered. Sent once per user. data = { userId, completedAt, via }. |
user.onboarding_skipped | The user skipped onboarding. Sent once per user, never after completion. Rekey only records it and blocks nothing. data = { userId, skippedAt, via }. |
user.deleted | An end-user account is deleted. |
user.erased | GDPR erasure, PII/auth hard-deleted, the user can never authenticate again. Propagate the erasure to your own copies. data.user = { id, erasedAt }. |
user.banned | An operator banned the end-user: every session and grant ended, and sign-in is refused until the ban is lifted. Subscriptions keep billing. data.user = { id, bannedAt }, data.sessionsRevoked. Never carries the reason. |
user.unbanned | An operator lifted the ban. They can sign in again; sessions the ban ended stay ended. data.user = { id, bannedAt: null }. |
session.created | An end-user signed in: password (including sign-up), magic link, OAuth, passkey or MFA completion. Never sent for a refresh or an organization switch. data.firstSignIn is true for the first session the user ever gets; data.platform and data.country say where it came from. |
session.revoked | A session is revoked (sign-out, admin action, or force-logout). |
mfa.enabled | The user turns on TOTP MFA. |
mfa.disabled | The user turns off MFA. |
password.changed | The user changes their password. |
email.verified | The user verifies their email address. |
subscription.activated | A subscription becomes ACTIVE (new or recovered). |
subscription.canceled | A subscription is canceled, by you, the end-user, dunning exhaustion, or the provider after failed retries. |
subscription.past_due | A renewal payment failed and the subscription is now PAST_DUE. |
subscription.entitlements_updated | What a subscription grants changed without its status changing: an operator wrote an entitlement override. Sent only when the resolved entitlements actually differ. |
subscription.trial_started | A subscription entered its trial. data.subscription carries trialEndsAt. |
subscription.trial_will_end | A trial ends within 3 days. Sent once per subscription and trial end date, and again if the trial is re-dated. |
payment.succeeded | A payment (initial or renewal) succeeds. |
payment.failed | A payment attempt fails. |
payment.refunded | A refund is recorded against a payment, whether the provider or an operator started it. One event per refund. |
dunning.case_opened | Failed-payment recovery starts for a past-due subscription. |
dunning.case_recovered | A past-due subscription pays before exhaustion, the case closes recovered. |
dunning.case_exhausted | Day-14 reached without recovery, Rekey cancels the subscription (provider-side too). |
device.registered | A new device signed in for an end-user, or a released one came back. A sign-in from an already-active device sends nothing. |
device.released | A device gave its slot back (the end-user, an operator or your server released it) and its sessions were revoked. |
device.blocked | An operator blocked a device. Its sessions were revoked and sign-in from it is refused. |
device.unblocked | An operator lifted a block. The device takes a slot again on its next sign-in. |
device.limit_reached | A new device was refused because the end-user is at their max_devices limit. The payload lists the active devices, so you can offer to release one. |
license.deactivated | A machine gave back its seat on a licence. |
credit.granted | Credits were added: an operator or Application-key grant, a refund, a credit-pack purchase or plan provisioning. data.credit carries the signed delta and the balance after it. |
credit.consumed | Credits were drawn down: POST /credits/consume, or usage past an included quota on a meter priced in credits. One delivery per charged usage record, so list events by name if a busy priced meter would flood a * endpoint. |
credit.adjusted | An operator corrected a balance. Kept apart from credit.consumed so a correction is never read as usage. |
organization.invitation.created | Someone was invited to an end-user organization. The payload never carries the invitation token. |
organization.invitation.accepted | An organization invitation was accepted, once per invitation. data.membership carries the new member and their role. |
contact.subscribed | Someone joined one of your lists, or your server added back a person who had left. A repeat that changes nothing sends nothing. |
contact.unsubscribed | Someone left a list: your server called the unsubscribe route, for example to sync your email tool, or you took them off in the panel. Once per change. |
contact.submission.created | A form sent extra fields and they were stored, such as a contact form message. data.submission.fields carries what the person typed. |
email.unsubscribed | Someone left notification email: the unsubscribe link in one of your custom notification templates, or your server set a topic to unsubscribed. data.topic names what they left, and is absent when they left every notification email. |
support.opened | An operator opened a support case on a user, so the account is in support mode. data.support lists the capabilities that are on and the devices the case covers. The operator's reason is never sent. |
support.updated | The capabilities or devices of an open support case changed: an operator edited it, or a device it named was released or blocked. data.support is the full state after the change. |
support.closed | A support case ended and its capabilities are off. data.support.closedCause says why: operator, banned, erased or device_released. |
Retries & idempotency
- Respond 2xx quickly (do slow work async). Any non-2xx or timeout is retried with backoff.
- Retries reuse the same
eventId. Dedupe on it so a redelivery is a no-op. - Order is best-effort, not guaranteed. Use
occurredAtif you need to reason about sequence.
Was this page helpful?
