Event webhooks
Implement a receiver from this page alone. Preserve the received bytes, authenticate before parsing, acknowledge quickly, and make processing idempotent and order-independent.
Envelope
Accept one JSON object with exactly this top-level shape:
{"type":"user.created","id":"evt_0123456789abcdef0123456789abcdef","timestamp":"2026-08-25T12:00:00Z","data":{}}
| Field | Type and meaning |
|---|---|
type | Non-null string naming one event from the six-event catalog below. |
id | Non-null string: evt_ followed by a 32-character generated id. Persist this as the delivery deduplication key. |
timestamp | Non-null RFC3339 UTC string. This is MINT time: the time Signet created the envelope, not the time this POST was attempted. |
data | Non-null JSON object whose fields depend on type. |
Six-event catalog
Handle every event below. A field marked nullable is present but may be JSON null; do not confuse JSON null with an omitted field.
user.created
| Data field | Exact JSON type | Meaning |
|---|---|---|
id | string or null | User id. |
email | string or null | Canonical email address. |
name | string or null | Display name; UTF-8 is allowed. |
image | string or null | Profile-image URL. |
emailVerified | boolean or null | Email-verification state. |
createdAt | RFC3339 UTC string or null | User creation time. |
updatedAt | RFC3339 UTC string or null | User update time. |
account.locked
| Data field | Exact JSON type | Meaning |
|---|---|---|
email | string, non-null | Canonical attempted email. It may have no user row. |
lockedUntil | RFC3339 UTC string, non-null | Current lock expiry. |
lockLevel | integer, non-null | Current escalation level. |
lastIp | string, non-null | Last client-IP value associated with the lock. |
session.created
| Data field | Exact JSON type | Meaning |
|---|---|---|
id | string or null | Session id. |
userId | string or null | Owning user id. |
expiresAt | RFC3339 UTC string or null | Session expiry. |
createdAt | RFC3339 UTC string or null | Session creation time. |
ipAddress | string or null | Recorded client address. |
userAgent | string or null | Recorded user-agent value. |
impersonatedBy | string, non-null when present | Include this field only for an impersonation session. It is omitted, never JSON null, for an ordinary session. |
session.created event contains the session token. The webhook is a system notification, not a credential-delivery path.session.annotated
| Data field | Exact JSON type | Meaning |
|---|---|---|
id | string, non-null | Newly committed session id. |
userId | string, non-null | Owning user id. |
label | string, non-null, at most 64 characters | Operator-configured post-sign-in label. |
session.annotated carries opaque ids and the operator's own label only. It contains no email, name, provider assertion, predicate fact, or session token.session.revoked
| Data field | Exact JSON type | Meaning |
|---|---|---|
id | string or null | Revoked session id. |
userId | string or null | Owning user id. |
reason | string, non-null enum | expired, sign_out, revoke_session, revoke_all, revoke_other_sessions, password_change, multi_session_revoke, session_replaced, two_factor_disabled, user_ban, admin_revoke_all, admin_revoke, or sso_provider_deleted. A build with the optional scim feature may also send scim_deprovision; accept that optional value even when the current sender does not have SCIM compiled in. |
user.deleted
| Data field | Exact JSON type | Meaning |
|---|---|---|
id | string, non-null | Erased user id. |
deleted | boolean literal true | Deletion marker. |
user.deleted as deliberate. It carries no email, name, image, or other erased identity field, because an erasure event must not re-export the PII just destroyed.Verify the signature before parsing
- Read
x-signet-timestampas a header string and keep that string verbatim. It is Unix SECONDS, not milliseconds. - Read the request body as raw bytes. Do this before any JSON parse.
- Compute HMAC-SHA256 with
[events].secretover: the verbatim timestamp header bytes, one ASCII.byte, then the raw received body bytes. - Lowercase-hex encode the 32-byte digest and compare it with
x-signet-signaturein constant time. - Only after authentication and freshness succeed, parse the JSON.
signed_input = UTF8(verbatim_x_signet_timestamp) || ASCII(".") || raw_received_body
expected = lowercase_hex(HMAC_SHA256(events_secret, signed_input))
In Node.js, decode both hex digests to equal-length buffers and call crypto.timingSafeEqual. In Python, call hmac.compare_digest on the expected and supplied lowercase-hex strings. Never use ordinary string equality for this comparison.
Check the correct timestamp for freshness
Apply freshness only to x-signet-timestamp. Parse it as Unix seconds and accept a recommended tolerance of ±5 minutes (300 seconds) around the receiver clock. Keep both sender and receiver clocks on sane NTP. Do not enforce a tighter lower bound on future timestamps: ordinary future clock skew happens, so preserve the full positive side of the tolerance.
Never use the envelope timestamp for delivery freshness. It is mint time and can be arbitrarily old during retries or an admin replay. A 24-hour-old envelope with a fresh valid x-signet-timestamp is an authenticated fresh delivery and must pass this check.
Make processing idempotent and unordered
Assume delivery is at-least-once and unordered. Return 2xx for a duplicate and deduplicate durable work on the envelope id. Admin replay is unbounded in age, so do not expire id-dedup records early merely because the normal retry window elapsed.
For state about one user or session, use the envelope timestamp as a happens-before hint. Drop or defer an event older than the entity state you already hold. Do not infer order from HTTP arrival; for example, session.revoked may arrive before session.created for the same session.
Plan for at most 8 attempts over at least 17 hours; intervals are not contractual. These are capacity bounds, not promised retry slots.
Acknowledge fast
The sender times out after 10 seconds. Authenticate, enqueue or durably record the event, and return 2xx immediately; process it asynchronously. A slow 2xx counts as a timeout and will be retried. Receiver idempotency is therefore mandatory.
Separate and rotate secrets
Use an [events].secret of at least 32 characters. Hand that secret only to event receivers. It MUST differ from [delivery.webhook].secret: HMAC verification is also HMAC signing authority, so a shared value lets an events receiver forge delivery webhooks.
Rotate in this order:
- Make the receiver accept both the old and new event secrets.
- Rotate
[events].secreton the operator side and redeploy Signet. - After one full retry horizon—at least 17 hours—make the receiver drop the old secret.
Run the receiver conformance kit
signet events verify-receiver \ --url https://app.example.com/signet/events \ --secret "$SIGNET_EVENTS_SECRET"
The command sends valid, invalid, stale, duplicate, old-envelope, out-of-order, non-ASCII, and verbatim-header probes. It prints one PASS or actionable FAIL line per case and exits non-zero if any case fails.
Test vectors
For each vector, use the secret and decimal header timestamp exactly as printed. The body is the UTF-8 byte sequence between the <body> markers, with no leading or trailing newline. The expected signature is lowercase hex.
Vector 1 — deletion envelope
secret: events-vector-secret-0123456789abcdef
x-signet-timestamp: 1770000000
<body>{"type":"user.deleted","id":"evt_0123456789abcdef0123456789abcdef","timestamp":"2026-08-25T12:00:00Z","data":{"id":"usr_123","deleted":true}}</body>
x-signet-signature: 3b9e7ac7faa78ee36b82c743091d3816aff4fe82ed1701cc5adb3a5f8d020b92
Vector 2 — non-ASCII UTF-8 name
secret: events-vector-secret-abcdef0123456789
x-signet-timestamp: 1770000060
<body>{"type":"user.created","id":"evt_fedcba9876543210fedcba9876543210","timestamp":"2026-08-25T12:01:00Z","data":{"id":"usr_unicode","email":"zoe@example.com","name":"Zoë 李","image":null,"emailVerified":true,"createdAt":"2026-08-25T12:00:00Z","updatedAt":"2026-08-25T12:00:00Z"}}</body>
x-signet-signature: 773350cf2f015715a5793dca417f62973abb5c22c3e44f1dd7618b1cb455ba04