Product documentation
Actions / Hooks: the hooks API
Integrate with the Actions/Hooks pipeline through the /api/v1/hooks management API — create a hook against a stage, send a test delivery, read delivery health, rotate the signing material, and verify the HMAC or Ed25519 detached-JWS signature on every delivery.
Actions / Hooks: the hooks API
The Actions / Hooks pipeline lets a tenant inject its own logic into the authentication flow by registering a webhook against a named stage — a fixed point in the auth pipeline with a fixed request/response contract. Depending on the stage, a hook either runs inline (blocking; it can augment or veto the flow) or fires as an asynchronous event (notify-only; it never affects the flow).
Every hook is configured through one API — /api/v1/hooks — served by the customer-plane
product-api behind the public api-gateway. This guide is the integration overview for
that whole surface: the lifecycle, the endpoint and configuration reference, delivery
health, signing rotation, and how a receiver verifies a delivery. Two companion guides go
deeper:
- Verifying the hook request signature — copy-paste Node/Python verification for both signing schemes, the replay window, and rotation overlap.
- Registration gate & user.created events — the per-stage request/response contracts and fail modes.
The generated endpoint reference is Hooks API.
For end users
A tenant administrator (or a machine client holding the right scopes) manages hooks through the customer plane. The typical lifecycle:
- Obtain a tenant-admin access token for your tenant (the same OIDC flow used to reach
the console — see Integrate an application). Reads need the
tenant:hooks.readscope; writes needtenant:hooks.write. - Create a hook against a stage with a target URL and a signing method. The response returns the signing material exactly once — store it.
- Send a test delivery to validate your endpoint end-to-end (reachability, TLS, and signature verification) before a real event fires.
- Read delivery health to see recent successes and failures for the hook.
- Rotate the signing material when you need to, with a graceful overlap so no delivery fails verification during the cut-over.
- Update or disable the hook (change the URL, toggle
enabled, adjust the fail mode), or delete it.
Tenant isolation is enforced on every call by the tnt claim in your token — there is no
tenant id in the path. A hook that belongs to another tenant (or to the other test/live
mode than your token) returns 404, never 403 — no existence leak.
Available stages
A stage fixes the hook's kind (inline vs event) and its request/response contract — you choose a stage, not a kind. The stages available today:
| Stage | Kind | Runs | Purpose |
|---|---|---|---|
token.pre-issuance | inline | at token issuance | Claims enrichment before a token is minted. |
registration.pre-create | inline | at sign-up, before the user row is written | Augment or veto a registration. |
user.created | event | after a user is created | Fire-and-forget user-created notification. |
Requesting a stage that is not in the catalog returns 400 unknown_stage; a stage that
exists but is not enabled in this release returns 400 stage_not_available.
Create a hook
POST /api/v1/hooks with the tenant:hooks.write scope. Supply an optional
Idempotency-Key header to make a retried create safe (a replay with the same key returns
the original response; a concurrent duplicate returns 409 idempotency_conflict).
POST /api/v1/hooks
Authorization: Bearer <tenant-admin token>
Content-Type: application/json
Idempotency-Key: 6b1e...optional...
{
"stage": "user.created",
"url": "https://hooks.acme.example/thoryn",
"enabled": true,
"description": "notify our CRM",
"signingAlg": "hmac"
}The 201 response is the hook plus, exactly once on create, the material you need to
verify deliveries. Which field carries it depends on signingAlg:
{
"id": "9f1c…",
"stage": "user.created",
"kind": "event",
"url": "https://hooks.acme.example/thoryn",
"enabled": true,
"failMode": null,
"timeoutMs": null,
"maxRetries": 0,
"description": "notify our CRM",
"signingAlg": "hmac",
"createdAt": "2026-07-24T09:30:00Z",
"updatedAt": "2026-07-24T09:30:00Z",
"secret": "kR8…shown once…",
"verificationKeys": null
}- For an
hmachook,secretholds the shared HMAC secret andverificationKeysisnull. The secret is never returned again — store it securely. - For an
ed25519hook,secretisnullandverificationKeysholds the public Ed25519 JWK(s); the private key is never returned. The same keys are served publicly atGET /api/v1/hooks/{id}/jwks.
Only one hook may exist per stage per tenant (per mode); a second returns 409 hook_exists.
Endpoint reference
| Method | Path | Description | Scope |
|---|---|---|---|
POST | /api/v1/hooks | Create a hook; returns the signing secret (or Ed25519 public keys) once. Honors Idempotency-Key. | tenant:hooks.write |
GET | /api/v1/hooks | List the tenant's hooks (cursor-paginated; never returns the secret). | tenant:hooks.read |
GET | /api/v1/hooks/{id} | Read one hook (never returns the secret). | tenant:hooks.read |
GET | /api/v1/hooks/{id}/deliveries | Recent delivery attempts (delivery health), newest first, cursor-paginated. | tenant:hooks.read |
POST | /api/v1/hooks/{id}/test | Fire a synthetic, signed delivery to validate the endpoint. | tenant:hooks.write |
POST | /api/v1/hooks/{id}/signing/rotate | Rotate the signing secret/key with a graceful overlap. | tenant:hooks.write |
GET | /api/v1/hooks/{id}/jwks | Public Ed25519 verification JWKS for an ed25519 hook. | none (public) |
PATCH | /api/v1/hooks/{id} | Update url / enabled / failMode / timeoutMs / maxRetries / description. | tenant:hooks.write |
DELETE | /api/v1/hooks/{id} | Delete the hook and its secret (204). | tenant:hooks.write |
List and delivery-health responses are a cursor-paginated envelope — an items array plus a
pageInfo object — accepting limit and cursor query parameters. The two management
scopes are granted to a tenant's customer-plane clients by the hub (migration
V88__add_hooks_scopes_to_customer_plane_clients.sql); a tenant admin can then delegate
them to a machine client the same way as any other tenant:* scope.
The public GET /api/v1/hooks/{id}/jwks endpoint is unauthenticated because it serves only
public key material. To avoid an existence oracle, an unknown, non-ed25519, or malformed
id returns an empty JWKS ({ "keys": [] }, 200), not a 404; the document is cacheable
(5-minute TTL).
Configuration
Fields accepted on POST /api/v1/hooks. PATCH accepts every field except stage and
signingAlg, which are fixed for the life of a hook (changing either is a DELETE plus a
fresh POST).
| Field | Type | Notes |
|---|---|---|
stage | string, required | One of the available stage ids. Immutable. |
url | string, required | Must be HTTPS and pass the SSRF guard (no private/loopback/link-local/metadata addresses). |
enabled | boolean | Defaults to true. Set false to keep the config but stop deliveries. |
signingAlg | string | hmac (default) or ed25519. Immutable. ed25519 is accepted only on stages whose executor emits a JWS. |
failMode | string | open or closed. Inline stages only — supplying it on an event stage returns 400 fail_mode_not_supported. Default is per-stage (below). |
timeoutMs | integer | Inline latency budget, 1 to the stage's ceiling. Ignored for event stages. Default is per-stage. |
maxRetries | integer | Delivery retry ceiling, 0 to 5. Defaults to 0. |
description | string | Free-text label for your own reference. |
Per-stage defaults and ceilings, from the stage catalog:
| Stage | failMode default | timeoutMs default | timeoutMs max | ed25519 accepted |
|---|---|---|---|---|
token.pre-issuance | open | 2000 | 5000 | yes |
registration.pre-create | closed | 3000 | 5000 | yes |
user.created | none | none | none | yes |
A timeoutMs or maxRetries outside its range returns 400 invalid_field with the exact
bound in the message. Requesting signingAlg: "ed25519" on a stage that does not support it
returns 400 asymmetric_not_supported_for_stage, so a hook never advertises a signature it
will not receive.
Where hook config runs
product-api owns the canonical hook config. For an inline stage it mirrors the routing
subset — and, for a stage that executes in another service, the HMAC secret in
envelope-encrypted form — to the executing service (the hub for token.pre-issuance,
identity-service for registration.pre-create) over an in-cluster, service-authenticated
channel, so the invoker signs on the hot path without an extra round-trip. For an event
stage the hook row is the subscription: the originating service ships the event to
product-api, which holds the secret and performs the single, tenant-facing signed
delivery.
Send a test delivery
POST /api/v1/hooks/{id}/test (scope tenant:hooks.write) fires a synthetic delivery at
the hook's configured URL through exactly the same path a real delivery uses — SSRF-guarded,
signed per the hook's signingAlg, and carrying the identical X-Thoryn-Signature* /
X-Thoryn-Timestamp / X-Thoryn-Hook-Stage headers. A passing test therefore proves your
signature check too. The body is unmistakably a probe ("event": "hook.test",
"test": true).
The 200 response reports the wire outcome:
{
"hookId": "9f1c…",
"stage": "user.created",
"outcome": "delivered",
"httpStatus": 200,
"latencyMs": 143,
"signingAlg": "hmac",
"attemptedAt": "2026-07-24T09:31: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 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 under
GET /api/v1/hooks/{id}/deliveries. See the
registration & events guide for the full probe
payload.
Read delivery health
GET /api/v1/hooks/{id}/deliveries (scope tenant:hooks.read) returns the hook's recent
delivery attempts, newest first, so you can see whether your endpoint is healthy without
reading Thoryn's logs:
{
"items": [
{ "id": "…", "stage": "user.created", "outcome": "delivered",
"attemptedAt": "2026-07-24T09:31:00Z", "error": null },
{ "id": "…", "stage": "user.created", "outcome": "failed",
"attemptedAt": "2026-07-24T09:25:00Z", "error": "tenant endpoint delivery failed" }
],
"pageInfo": { "…": "…" }
}outcomeisdeliveredorfailed;errorcarries a sanitised, stable reason on a failed attempt and isnullon a delivered one.- Only delivery legs
product-apiperforms itself are recorded today — theuser.createdevent stage and test deliveries. Inline stages (token.pre-issuance,registration.pre-create) execute in another service and are not yet reported here (a tracked follow-up). - The table is bounded per hook and is not the verifiable tenant audit log — signing rotations, for example, are recorded there separately.
Rotate the signing material
POST /api/v1/hooks/{id}/signing/rotate (scope tenant:hooks.write) issues fresh signing
material without deleting the hook. The body is optional:
POST /api/v1/hooks/{id}/signing/rotate
Authorization: Bearer <tenant-admin token>
Content-Type: application/json
{ "overlapWindowSeconds": 86400 }overlapWindowSeconds defaults to 24h and is clamped to [60, 604800] (1 minute to 7 days).
The new material is active immediately; the previous material keeps validating until
previousExpiresAt in the response, so you can adopt the new material with zero failed
verifications. During that window Thoryn dual-signs (see
Verifying deliveries below). The response returns the new material
once — the new secret for an hmac hook, or the new public verificationKeys for an
ed25519 hook — plus previousExpiresAt.
Only one rotation may be in flight: starting a second while the overlap is still open
returns 409 rotation_in_flight. Every rotation writes a tenant audit row (it records the
stage, algorithm, window, and public key ids — never the material). Full mechanics and the
receiver-side handling are in the
signing guide.
Verifying deliveries
Verify the signature on every request before you trust the body. Each delivery carries:
| Header | Value |
|---|---|
X-Thoryn-Signature | HMAC scheme: sha256=<lowercase hex> of HMAC-SHA256(canonicalString, secret). |
X-Thoryn-Signature-JWS | Ed25519 scheme: a detached JWS base64url(header)..base64url(signature) (the middle payload segment is empty). |
X-Thoryn-Timestamp | Unix time in seconds — the value used in the canonical string. |
X-Thoryn-Hook-Stage | The stage id, so one endpoint can route across stages. |
A hook sends one signature header, chosen by its signingAlg. Both schemes sign the
same canonical string — the timestamp and the raw request body, joined by a single ASCII
dot:
{X-Thoryn-Timestamp}.{raw request body bytes}Capture the raw body before any JSON middleware parses it — re-serialising parsed JSON can reorder keys or change whitespace and will break the signature.
HMAC (default)
- Read
X-Thoryn-Timestampand reject anything more than ±5 minutes from your clock (replay defence). - Recompute
HMAC-SHA256("{timestamp}.{raw body}", secret)as lowercase hex, prependsha256=, and compare againstX-Thoryn-Signaturein constant time. - Only then parse the body.
Ed25519 detached-JWS
- Reject on the same ±5-minute timestamp window.
- Fetch the hook's JWKS from
GET /api/v1/hooks/{id}/jwks(no auth; cache it), and select the key whosekidmatches thekidin the JWS protected header. The header is{ "alg": "EdDSA", "kid": "…" }; the JWK is an OKP Ed25519 public key. - Rebuild the signing input as
header_b64 + "." + base64url("{timestamp}.{raw body}")— the JWS is detached, so the middle segment on the wire is empty and you supply the payload from the body plus the timestamp. - Verify the Ed25519 signature (the third JWS segment) over that signing input with the selected public key.
During a rotation overlap
While a graceful rotation is open the request is dual-signed: the signature header
carries the current and the previous signature, comma-separated
(sha256=<new>,sha256=<old> for HMAC; <new-jws>,<old-jws> for Ed25519, each with its own
kid). Split the header on ,, verify each candidate, and accept if any one matches
(still inside the replay window). For Ed25519 the JWKS carries both public keys for the
window, so a receiver that re-fetches on an unknown kid finds either.
The signer is byte-for-byte identical across the hub, identity-service, and product-api delivery legs, so one verification routine works for every stage. Copy-paste Node.js and Python verifiers for both schemes are in the signing guide.
Troubleshooting
Errors are RFC 9457 problem-details (application/problem+json) with a machine-readable
errorCode extension.
Status / errorCode | Cause |
|---|---|
400 unknown_stage | stage is not a catalog stage id. |
400 stage_not_available | The stage exists but is not enabled in this release. |
400 invalid_url | url is blank, not https://, or resolves to a private/loopback/metadata address (SSRF guard). |
400 invalid_signing_alg | signingAlg is neither hmac nor ed25519. |
400 asymmetric_not_supported_for_stage | signingAlg: "ed25519" on a stage whose executor does not emit a JWS. |
400 fail_mode_not_supported | failMode supplied on an event stage (e.g. user.created). |
400 invalid_fail_mode | failMode is not open or closed. |
400 invalid_field | timeoutMs or maxRetries is out of range (the message carries the bound). |
401 missing_tnt / invalid_tnt / missing_sub | The access token lacks a valid tnt or sub claim. |
404 not_found | The hook is missing, belongs to another tenant, or lives in the other test/live mode. |
409 hook_exists | A hook already exists for that stage (within your mode). |
409 idempotency_conflict | A request with the same Idempotency-Key is already in progress. |
409 rotation_in_flight | A signing rotation overlap is still open for this hook. |
409 version_conflict | The hook was modified concurrently; retry. |
502 vault_unavailable | Secret storage is temporarily unavailable; retry. |
502 hook_executor_unavailable | On rotate, the signing executor could not be updated; nothing was rotated. Retry. |
Signature-verification symptoms (signed the parsed JSON instead of the raw body, dropped the
sha256= prefix, clock skew, hex case) are covered in the
signing guide's troubleshooting.
Security notes
- Signing material is shown once. The HMAC secret (or the Ed25519 public keys) is returned only on create and on rotate, and the secret / private key is stored at Thoryn only in Vault-Transit envelope-encrypted form — never plaintext at rest, never logged. Lost an HMAC secret? Rotate; you do not delete and re-create.
- Verify before you parse, in constant time. Treat the body as untrusted until the
signature checks out; reject with
401on any mismatch or a stale timestamp. Use a constant-time compare for the HMAC hex. - Outbound URLs are tenant-controlled and SSRF-guarded. Hook URLs must be HTTPS and are re-validated against the SSRF guard at delivery time, not just at registration — a URL that later resolves to a private range is refused, and the delivery is recorded as failed.
- Tenant isolation is symmetric. Every read and write is scoped by the
tntclaim, and cross-tenant (or cross-mode) access returns404, never403. - Prefer Ed25519 when you would rather not custody a shared secret. With
signingAlg: "ed25519"your receiver verifies against a Thoryn-published public key it never has to keep secret; the default HMAC scheme is a shared per-hook secret.