← Back to docs

Rekey 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

  1. Operator panel → Applications → pick one → Developer → Webhooks → Add endpoint.
  2. Paste your HTTPS URL and subscribe to all events (recommended) or a specific subset.
  3. 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.

EventFires when
user.createdAn end-user account is created (any sign-up path).
user.updatedProfile, email, or metadata changes.
user.onboarding_completedOnboarding was marked complete after every required profile field was answered. Sent once per user. data = { userId, completedAt, via }.
user.onboarding_skippedThe user skipped onboarding. Sent once per user, never after completion. Rekey only records it and blocks nothing. data = { userId, skippedAt, via }.
user.deletedAn end-user account is deleted.
user.erasedGDPR erasure, PII/auth hard-deleted, the user can never authenticate again. Propagate the erasure to your own copies. data.user = { id, erasedAt }.
user.bannedAn 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.unbannedAn operator lifted the ban. They can sign in again; sessions the ban ended stay ended. data.user = { id, bannedAt: null }.
session.createdAn 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.revokedA session is revoked (sign-out, admin action, or force-logout).
mfa.enabledThe user turns on TOTP MFA.
mfa.disabledThe user turns off MFA.
password.changedThe user changes their password.
email.verifiedThe user verifies their email address.
subscription.activatedA subscription becomes ACTIVE (new or recovered).
subscription.canceledA subscription is canceled, by you, the end-user, dunning exhaustion, or the provider after failed retries.
subscription.past_dueA renewal payment failed and the subscription is now PAST_DUE.
subscription.entitlements_updatedWhat a subscription grants changed without its status changing: an operator wrote an entitlement override. Sent only when the resolved entitlements actually differ.
subscription.trial_startedA subscription entered its trial. data.subscription carries trialEndsAt.
subscription.trial_will_endA trial ends within 3 days. Sent once per subscription and trial end date, and again if the trial is re-dated.
payment.succeededA payment (initial or renewal) succeeds.
payment.failedA payment attempt fails.
payment.refundedA refund is recorded against a payment, whether the provider or an operator started it. One event per refund.
dunning.case_openedFailed-payment recovery starts for a past-due subscription.
dunning.case_recoveredA past-due subscription pays before exhaustion, the case closes recovered.
dunning.case_exhaustedDay-14 reached without recovery, Rekey cancels the subscription (provider-side too).
device.registeredA 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.releasedA device gave its slot back (the end-user, an operator or your server released it) and its sessions were revoked.
device.blockedAn operator blocked a device. Its sessions were revoked and sign-in from it is refused.
device.unblockedAn operator lifted a block. The device takes a slot again on its next sign-in.
device.limit_reachedA 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.deactivatedA machine gave back its seat on a licence.
credit.grantedCredits 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.consumedCredits 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.adjustedAn operator corrected a balance. Kept apart from credit.consumed so a correction is never read as usage.
organization.invitation.createdSomeone was invited to an end-user organization. The payload never carries the invitation token.
organization.invitation.acceptedAn organization invitation was accepted, once per invitation. data.membership carries the new member and their role.
contact.subscribedSomeone joined one of your lists, or your server added back a person who had left. A repeat that changes nothing sends nothing.
contact.unsubscribedSomeone 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.createdA form sent extra fields and they were stored, such as a contact form message. data.submission.fields carries what the person typed.
email.unsubscribedSomeone 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.openedAn 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.updatedThe 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.closedA 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 occurredAt if you need to reason about sequence.
Need a hand? Talk to the team.

Was this page helpful?

Webhooks: events, signatures and retries | Rekey