threshold1

Webhooks Reference

The event catalog, real payload shapes, signature verification, and retry behavior for Threshold1 webhooks. For the raw HTTP calls to register, list, and delete a webhook endpoint — including a real, currently-open deletion limitation — see the Webhooks API Reference instead; this page doesn't repeat those.

Event catalog

Eleven event types exist today, confirmed directly against every dispatchEvent() call site in the codebase — not assumed from the type list alone. The envelope around every delivery is the same regardless of event type:

{
  "id": "<event id>",
  "type": "<event type>",
  "created_at": "<ISO timestamp>",
  "data": { /* shape below, per event type */ }
}

What varies is data. These shapes are documented exactly as they're actually constructed at each call site, including the places they're inconsistent with each other — smoothing that over would be less useful than knowing where the sharp edges actually are.

user.registered / user.login

{ "user_id": "9c4e2b7a-...", "email": "dev@example.com" }

Fired by OTP, magic link, passkey, device recognition, and backup codes on a successful login or registration. One exception: when fired from the BYOAM confirm callback, the payload carries a third field, byoam_method, naming which registered method vouched for the login. Every other trigger omits it.

user.login_failed

{ "email": "dev@example.com", "method": "otp", "failure_reason": "Invalid or expired code." }

method is one of "otp", "magic", or "passkey" (device recognition also reports "otp", since it's an OTP step-up under the hood). Fired by OTP, magic link, passkey, and device recognition on any verify failure. Backup codes never fires this event — a wrong or expired backup code produces no webhook at all; only a successful login or a risk-engine block does. If you're relying on user.login_failed to catch every failed login attempt, backup-code failures won't show up.

user.registration_failed

{ "email": "dev@example.com", "method": "passkey", "failure_reason": "Verification failed." }

Only passkey registration fires this event. OTP, magic link, and device recognition can't tell in advance whether a verify attempt would have registered a new user or logged in an existing one — a failed verify on those methods is reported as user.login_failed regardless. Passkey has a genuinely separate registration endpoint (WebAuthn registration is its own API call), so it's the only method that can fire this one.

auth.suspicious_login

{
  "user_id": "9c4e2b7a-...",
  "email": "dev@example.com",
  "risk_score": 72,
  "risk_signals": ["new_country", "recent_failures"],
  "action_taken": "block",
  "ip_address": "203.0.113.4",
  "country_code": "US",
  "device_hint": "Chrome on macOS"
}

Fired whenever the risk engine flags an attempt (challenge or block), across every method that risk-scores. Shape is consistent everywhere this fires.

passkey.created / passkey.deleted

{ "user_id": "9c4e2b7a-...", "email": "dev@example.com", "credential_id": "AbC123..." }

passkey.created fires on first successful passkey registration. passkey.deleted fires from three different places (a user removing their own passkey, an admin removing it from the Users dashboard, and the by-external API) — all three send this identical shape.

user.deleted / user.enabled

{ "user_id": "9c4e2b7a-...", "email": "dev@example.com" }

Fired from the dashboard's Users page admin actions.

user.disabled

{ "user_id": "9c4e2b7a-...", "email": "dev@example.com", "reason": "Suspicious activity" }

Same shape as user.enabled, plus a reason field the other two admin events don't carry.

session.revoked

{ "user_id": "9c4e2b7a-..." }

Fired on logout. The one event with no email field at all — every other event type in this catalog includes it; this one only carries user_id.

Signature verification

Every delivery is signed and sent with an X-Threshold1-Signature header: the hex-encoded HMAC-SHA256 digest of the raw JSON body, keyed by your webhook's secret (returned once, at registration — see the API Reference). No prefix — the header is the raw hex digest, not sha256=....

signature = HMAC-SHA256(secret, raw_request_body) → hex

Here's a real, computed example — not illustrative pseudocode. Given this secret and this exact raw body:

secret   = whsec_test_5f3a8e2c9d1b4a6f8e0c2d4b6a8f0e2c
rawBody  = {"id":"4e1b2c3a-0000-4a11-8888-aabbccddeeff","type":"user.login","created_at":"2026-08-26T09:15:00.000Z","data":{"user_id":"9c4e2b7a-1111-4c22-9999-112233445566","email":"dev@example.com"}}

the real X-Threshold1-Signature header value is:

4754fecb8778b7e0f807a35ba9e17aa76a0a04be7436d6807055f63d1e6d7810

Verify it with the same algorithm on your side — this is the actual code that produces and checks that value above (Node's built-in crypto, no dependency needed):

const { createHmac, timingSafeEqual } = require("crypto");

function verifyThreshold1Signature(secret, rawBody, headerSignature) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const expectedBuf = Buffer.from(expected, "hex");
  const actualBuf = Buffer.from(headerSignature, "hex");
  return (
    expectedBuf.length === actualBuf.length &&
    timingSafeEqual(expectedBuf, actualBuf)
  );
}

// Using the real values above:
verifyThreshold1Signature(
  "whsec_test_5f3a8e2c9d1b4a6f8e0c2d4b6a8f0e2c",
  '{"id":"4e1b2c3a-0000-4a11-8888-aabbccddeeff","type":"user.login","created_at":"2026-08-26T09:15:00.000Z","data":{"user_id":"9c4e2b7a-1111-4c22-9999-112233445566","email":"dev@example.com"}}',
  "4754fecb8778b7e0f807a35ba9e17aa76a0a04be7436d6807055f63d1e6d7810"
); // → true

Verify against the raw request body bytes, not a re-serialized parse of it — parsing the JSON and calling JSON.stringify() again on your side can produce different key ordering or whitespace than what was actually signed, which breaks verification even though the data is identical. Read the body as text (or a raw buffer) before you parse it for anything else.

Retry and delivery behavior

The delivery backoff schedule is 30 seconds, then 5 minutes, then 1 hour after the first failure, for 4 total attempts before a delivery is marked permanently failed.

That schedule only actually applies once a day. The worker that processes due retries (dispatchPendingEvents()) doesn't run on its own interval — it's invoked once, by the daily Vercel Cron job at 03:00 UTC. A delivery whose computed next_retry_at was 30 seconds away won't actually be retried until the next time that cron job runs, which can be up to 24 hours later. This is a real, current limitation in how often retries are processed, not a flaw in the backoff math itself — the schedule is computed correctly; it just isn't polled at anywhere near the cadence it targets. If your integration needs delivery within the sub-hour window the schedule implies, don't rely on it today — the first delivery attempt (made immediately when the event fires) is real-time regardless; it's only retries after a failure that are affected.

What's next

  • Webhooks API Reference — the raw POST/GET/DELETE calls to register, list, and remove a webhook, including a real, currently-open issue deleting a webhook that already has delivery history.