Skip to content

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:

  • url must be HTTPS and pass the SSRF guard (no private / loopback / metadata IPs).
  • registration.pre-create accepts failMode (open / closed, default closed) and timeoutMs (≤ 5000).
  • user.created is an event stage — it has no failMode or timeoutMs; supplying failMode is rejected 400 fail_mode_not_supported.
  • One hook per stage per tenant (409 hook_exists on a second).

Verifying the signature

Every request Thoryn sends carries:

HeaderValue
X-Thoryn-Signaturesha256=<hex>HMAC-SHA256("{timestamp}.{rawBody}", secret)
X-Thoryn-TimestampUnix seconds; reject requests outside ±5 minutes (replay defence)
X-Thoryn-Hook-Stagethe 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 }
  • outcomedelivered (your endpoint returned 2xx) or failed (non-2xx, timeout, network error, or the URL was SSRF-rejected at delivery time).
  • httpStatus — your endpoint's HTTP status when it was reached; null when it was not (SSRF rejection, signing failure, DNS / connection error).
  • error — a stable, sanitised reason on failure; null on 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 omit action). Optionally return normalised attributes — only givenName, familyName, and locale are merged (an allowlist; each value is trimmed and capped at 256 chars). email is 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. Your reason is 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 2xx to acknowledge. Use eventId for 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

SymptomLikely cause
Sign-ups fail after adding a registration.pre-create hookFail 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 effectDeny must be action: "deny" in a 200 response. A non-2xx is treated as a failure (fail-mode path), not a deny.
Signature check failsSign 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 createYou supplied failMode for user.created; event stages have none.
No user.created deliveriesThe subscription is disabled, or your endpoint keeps failing and the event was dropped after max attempts.
Hook URL rejected on createurl must be HTTPS and must not resolve to a private/loopback/metadata address (SSRF guard).
Test delivery returns outcome: failedYour 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 404The 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.