Product documentation
Actions / Hooks: registration gate & user.created events
The registration.pre-create inline stage (augment or veto a sign-up) and the user.created async event stage — request/response contracts, fail modes, and how tenants configure them.
Actions / Hooks: registration gate & user.created events
The Actions/Hooks pipeline lets a tenant inject custom logic into the authentication flow by registering a webhook against a named stage. This guide covers the two stages added in slice H3:
registration.pre-create— an inline stage that runs before a new user row is created. Your endpoint may normalise/augment the profile or veto the sign-up.user.created— an asynchronous event stage. After a user is created, Thoryn delivers a fire-and-forget notification to your endpoint.
Both are configured through the one hook API — POST /api/v1/hooks — the same surface as
the existing token.pre-issuance stage. token.pre-issuance (claims enrichment at token
issuance) is documented separately.
Configuring a hook
Create a hook with the tenant-admin API (scope tenant:hooks.write). The stage fixes the
kind, so you do not choose inline-vs-event — you choose a stage.
POST /api/v1/hooks
Authorization: Bearer <tenant-admin token>
Content-Type: application/json
{ "stage": "registration.pre-create", "url": "https://hooks.acme.example/registration" }The response returns the hook plus the HMAC signing secret exactly once — store it; it is never returned again (rotation is delete + re-create):
{ "id": "…", "stage": "registration.pre-create", "kind": "inline",
"url": "https://hooks.acme.example/registration", "enabled": true,
"failMode": "closed", "timeoutMs": 3000, "secret": "kR8…once…" }Constraints enforced on create:
urlmust be HTTPS and pass the SSRF guard (no private / loopback / metadata IPs).registration.pre-createacceptsfailMode(open/closed, default closed) andtimeoutMs(≤ 5000).user.createdis an event stage — it has nofailModeortimeoutMs; supplyingfailModeis rejected400 fail_mode_not_supported.- One hook per stage per tenant (
409 hook_existson a second).
Verifying the signature
Every request Thoryn sends carries:
| Header | Value |
|---|---|
X-Thoryn-Signature | sha256=<hex> — HMAC-SHA256("{timestamp}.{rawBody}", secret) |
X-Thoryn-Timestamp | Unix seconds; reject requests outside ±5 minutes (replay defence) |
X-Thoryn-Hook-Stage | the stage id, so one endpoint can route across stages |
Recompute the HMAC over "{X-Thoryn-Timestamp}.{raw request body}" and compare in constant
time. Reject on mismatch or a stale timestamp. For the exact canonical string, copy-paste
Node/Python verification, the replay window, and X-Thoryn-Hook-Stage routing, see the
dedicated Verifying the hook request signature guide — the scheme
is identical across every stage.
Send a test delivery
Once a hook is registered, fire a synthetic delivery at it to validate your endpoint
end-to-end — reachability, TLS, and signature verification — without waiting for a real
event (scope tenant:hooks.write):
POST /api/v1/hooks/{id}/test
Authorization: Bearer <tenant-admin token>Thoryn POSTs a clearly-marked synthetic body to the hook's configured URL, signed exactly
like a real delivery — the same X-Thoryn-Signature (or X-Thoryn-Signature-JWS for an
ed25519 hook), X-Thoryn-Timestamp, and X-Thoryn-Hook-Stage headers — so a passing test
proves your signature check too. The body is unmistakably a probe:
{ "event": "hook.test", "test": true, "hookId": "…", "stage": "user.created",
"tenantId": "…", "occurredAt": "2026-07-22T09:30:00Z",
"message": "Synthetic test delivery from Thoryn to verify this endpoint's configuration. No real event occurred." }The response reports what happened on the wire:
{ "hookId": "…", "stage": "user.created", "outcome": "delivered",
"httpStatus": 200, "latencyMs": 143, "signingAlg": "hmac",
"attemptedAt": "2026-07-22T09:30:00Z", "error": null }outcome—delivered(your endpoint returned2xx) orfailed(non-2xx, timeout, network error, or the URL was SSRF-rejected at delivery time).httpStatus— your endpoint's HTTP status when it was reached;nullwhen it was not (SSRF rejection, signing failure, DNS / connection error).error— a stable, sanitised reason on failure;nullon success.
The test attempt is recorded in the delivery-health model, so it also appears in
GET /api/v1/hooks/{id}/deliveries alongside real deliveries. A test delivery is
tenant- and mode-scoped like every other single-hook operation: a hook that belongs to
another tenant — or lives in the other test/live mode — returns 404 (a test-mode
credential can never probe a live hook, and vice-versa).
Stage: registration.pre-create (inline)
Fires at POST /register, before the user row is persisted. Latency budget 3000 ms
(clamped to a 5 s hard cap).
Request (Thoryn → your endpoint):
{
"stage": "registration.pre-create",
"profile": { "email": "jane@example.com", "givenName": "jane", "familyName": "doe", "locale": null }
}Response (your endpoint → Thoryn), HTTP 200:
-
Allow (default): return
action: "allow"(or omitaction). Optionally return normalisedattributes— onlygivenName,familyName, andlocaleare merged (an allowlist; each value is trimmed and capped at 256 chars).emailis never augmentable — it is the identity anchor.{ "action": "allow", "attributes": { "givenName": "Jane", "familyName": "Doe", "locale": "nl-NL" } } -
Deny: return
action: "deny". The sign-up is rejected. Yourreasonis for your own logs — it is not surfaced to the end user (a stable, non-leaky code is returned to the caller instead).{ "action": "deny", "reason": "disposable email domain" }
Fail mode (the important bit)
registration.pre-create defaults to fail-CLOSED: if your endpoint errors, times out,
is SSRF-rejected, or returns an unparseable body, the sign-up is rejected. This is the
product-owner-pinned default — a screening gate that fails open admits exactly the users it
exists to reject.
- CLOSED (default): a hook failure rejects the registration with a stable, non-leaky error. Trade-off: a broken hook endpoint blocks all sign-ups for that tenant.
- OPEN (set
"failMode": "open"): a hook failure proceeds with the sign-up unchanged.
An explicit action: "deny" always rejects, regardless of fail mode.
Stage: user.created (async event)
After the user row commits, Thoryn enqueues a fire-and-forget event and delivers it off the request path. It can never block or fail a sign-up, and your response body is ignored.
Event (Thoryn → your endpoint), HMAC-signed like the inline stage:
{
"eventId": "…",
"type": "user.created",
"tenantId": "…",
"occurredAt": "2026-07-16T09:30:00Z",
"data": { "userId": "…", "email": "jane@example.com", "givenName": "Jane", "familyName": "Doe" }
}Delivery semantics:
- Retried with exponential backoff on failure; dropped after max attempts (dead-lettered).
- Return any
2xxto acknowledge. UseeventIdfor idempotent processing (deliveries may repeat under retry). - There is no fail mode — a dead endpoint never affects the flow.
How config reaches the executor
product-api owns the canonical hook config. For the inline stage it mirrors the routing
subset plus the HMAC secret to identity-service (over an in-cluster, service-authenticated
channel; the secret is stored envelope-encrypted at rest) so the invoker can sign without a
second round-trip on the hot path. For the event stage the hook row is the subscription:
identity-service ships the event to product-api, which holds the secret and performs the
single tenant-facing, HMAC-signed delivery.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Sign-ups fail after adding a registration.pre-create hook | Fail mode is CLOSED (default) and your endpoint is erroring/timing out. Fix the endpoint, or set failMode: open if a blip should not block sign-up. |
| Deny isn't taking effect | Deny must be action: "deny" in a 200 response. A non-2xx is treated as a failure (fail-mode path), not a deny. |
| Signature check fails | Sign over "{X-Thoryn-Timestamp}.{raw body}" (not the parsed JSON), hex lowercase, and compare against the value after the sha256= prefix. |
400 fail_mode_not_supported on create | You supplied failMode for user.created; event stages have none. |
No user.created deliveries | The subscription is disabled, or your endpoint keeps failing and the event was dropped after max attempts. |
| Hook URL rejected on create | url must be HTTPS and must not resolve to a private/loopback/metadata address (SSRF guard). |
Test delivery returns outcome: failed | Your endpoint was unreachable/errored, or its URL now resolves to a private range (re-checked against the SSRF guard at delivery). Check httpStatus / error in the response and the recorded attempt under /deliveries. |
Test delivery returns 404 | The hook id belongs to another tenant, or to the other test/live mode than your token — a cross-mode credential cannot probe it. |
Security notes
- The signing secret is shown once on create and stored (at Thoryn) only in encrypted form. Rotate by deleting and re-creating the hook.
- Hook URLs are re-validated against the SSRF guard at invocation, not just at registration — a URL that later resolves to a private range is refused.
- Deny reasons and internal errors are never echoed to end users; only stable codes are.