Signet

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":{}}
FieldType and meaning
typeNon-null string naming one event from the six-event catalog below.
idNon-null string: evt_ followed by a 32-character generated id. Persist this as the delivery deduplication key.
timestampNon-null RFC3339 UTC string. This is MINT time: the time Signet created the envelope, not the time this POST was attempted.
dataNon-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 fieldExact JSON typeMeaning
idstring or nullUser id.
emailstring or nullCanonical email address.
namestring or nullDisplay name; UTF-8 is allowed.
imagestring or nullProfile-image URL.
emailVerifiedboolean or nullEmail-verification state.
createdAtRFC3339 UTC string or nullUser creation time.
updatedAtRFC3339 UTC string or nullUser update time.

account.locked

Data fieldExact JSON typeMeaning
emailstring, non-nullCanonical attempted email. It may have no user row.
lockedUntilRFC3339 UTC string, non-nullCurrent lock expiry.
lockLevelinteger, non-nullCurrent escalation level.
lastIpstring, non-nullLast client-IP value associated with the lock.

session.created

Data fieldExact JSON typeMeaning
idstring or nullSession id.
userIdstring or nullOwning user id.
expiresAtRFC3339 UTC string or nullSession expiry.
createdAtRFC3339 UTC string or nullSession creation time.
ipAddressstring or nullRecorded client address.
userAgentstring or nullRecorded user-agent value.
impersonatedBystring, non-null when presentInclude this field only for an impersonation session. It is omitted, never JSON null, for an ordinary session.
No session.created event contains the session token. The webhook is a system notification, not a credential-delivery path.

session.annotated

Data fieldExact JSON typeMeaning
idstring, non-nullNewly committed session id.
userIdstring, non-nullOwning user id.
labelstring, non-null, at most 64 charactersOperator-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 fieldExact JSON typeMeaning
idstring or nullRevoked session id.
userIdstring or nullOwning user id.
reasonstring, non-null enumexpired, 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 fieldExact JSON typeMeaning
idstring, non-nullErased user id.
deletedboolean literal trueDeletion marker.
Treat the no-PII shape of 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

  1. Read x-signet-timestamp as a header string and keep that string verbatim. It is Unix SECONDS, not milliseconds.
  2. Read the request body as raw bytes. Do this before any JSON parse.
  3. Compute HMAC-SHA256 with [events].secret over: the verbatim timestamp header bytes, one ASCII . byte, then the raw received body bytes.
  4. Lowercase-hex encode the 32-byte digest and compare it with x-signet-signature in constant time.
  5. 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.

Never parse then re-serialize the JSON for verification. Whitespace, member order, escaping, and non-ASCII UTF-8 spellings can all change while the JSON value stays equivalent; the signature authenticates the original bytes, not an equivalent value.

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:

  1. Make the receiver accept both the old and new event secrets.
  2. Rotate [events].secret on the operator side and redeploy Signet.
  3. 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