Skip to content

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:

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:

  1. 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.read scope; writes need tenant:hooks.write.
  2. Create a hook against a stage with a target URL and a signing method. The response returns the signing material exactly once — store it.
  3. Send a test delivery to validate your endpoint end-to-end (reachability, TLS, and signature verification) before a real event fires.
  4. Read delivery health to see recent successes and failures for the hook.
  5. Rotate the signing material when you need to, with a graceful overlap so no delivery fails verification during the cut-over.
  6. 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:

StageKindRunsPurpose
token.pre-issuanceinlineat token issuanceClaims enrichment before a token is minted.
registration.pre-createinlineat sign-up, before the user row is writtenAugment or veto a registration.
user.createdeventafter a user is createdFire-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 hmac hook, secret holds the shared HMAC secret and verificationKeys is null. The secret is never returned again — store it securely.
  • For an ed25519 hook, secret is null and verificationKeys holds the public Ed25519 JWK(s); the private key is never returned. The same keys are served publicly at GET /api/v1/hooks/{id}/jwks.

Only one hook may exist per stage per tenant (per mode); a second returns 409 hook_exists.

Endpoint reference

MethodPathDescriptionScope
POST/api/v1/hooksCreate a hook; returns the signing secret (or Ed25519 public keys) once. Honors Idempotency-Key.tenant:hooks.write
GET/api/v1/hooksList 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}/deliveriesRecent delivery attempts (delivery health), newest first, cursor-paginated.tenant:hooks.read
POST/api/v1/hooks/{id}/testFire a synthetic, signed delivery to validate the endpoint.tenant:hooks.write
POST/api/v1/hooks/{id}/signing/rotateRotate the signing secret/key with a graceful overlap.tenant:hooks.write
GET/api/v1/hooks/{id}/jwksPublic 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).

FieldTypeNotes
stagestring, requiredOne of the available stage ids. Immutable.
urlstring, requiredMust be HTTPS and pass the SSRF guard (no private/loopback/link-local/metadata addresses).
enabledbooleanDefaults to true. Set false to keep the config but stop deliveries.
signingAlgstringhmac (default) or ed25519. Immutable. ed25519 is accepted only on stages whose executor emits a JWS.
failModestringopen or closed. Inline stages only — supplying it on an event stage returns 400 fail_mode_not_supported. Default is per-stage (below).
timeoutMsintegerInline latency budget, 1 to the stage's ceiling. Ignored for event stages. Default is per-stage.
maxRetriesintegerDelivery retry ceiling, 0 to 5. Defaults to 0.
descriptionstringFree-text label for your own reference.

Per-stage defaults and ceilings, from the stage catalog:

StagefailMode defaulttimeoutMs defaulttimeoutMs maxed25519 accepted
token.pre-issuanceopen20005000yes
registration.pre-createclosed30005000yes
user.creatednonenonenoneyes

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
}
  • 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 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 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": { "…": "…" }
}
  • outcome is delivered or failed; error carries a sanitised, stable reason on a failed attempt and is null on a delivered one.
  • Only delivery legs product-api performs itself are recorded today — the user.created event 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:

HeaderValue
X-Thoryn-SignatureHMAC scheme: sha256=<lowercase hex> of HMAC-SHA256(canonicalString, secret).
X-Thoryn-Signature-JWSEd25519 scheme: a detached JWS base64url(header)..base64url(signature) (the middle payload segment is empty).
X-Thoryn-TimestampUnix time in seconds — the value used in the canonical string.
X-Thoryn-Hook-StageThe 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)

  1. Read X-Thoryn-Timestamp and reject anything more than ±5 minutes from your clock (replay defence).
  2. Recompute HMAC-SHA256("{timestamp}.{raw body}", secret) as lowercase hex, prepend sha256=, and compare against X-Thoryn-Signature in constant time.
  3. Only then parse the body.

Ed25519 detached-JWS

  1. Reject on the same ±5-minute timestamp window.
  2. Fetch the hook's JWKS from GET /api/v1/hooks/{id}/jwks (no auth; cache it), and select the key whose kid matches the kid in the JWS protected header. The header is { "alg": "EdDSA", "kid": "…" }; the JWK is an OKP Ed25519 public key.
  3. 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.
  4. 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 / errorCodeCause
400 unknown_stagestage is not a catalog stage id.
400 stage_not_availableThe stage exists but is not enabled in this release.
400 invalid_urlurl is blank, not https://, or resolves to a private/loopback/metadata address (SSRF guard).
400 invalid_signing_algsigningAlg is neither hmac nor ed25519.
400 asymmetric_not_supported_for_stagesigningAlg: "ed25519" on a stage whose executor does not emit a JWS.
400 fail_mode_not_supportedfailMode supplied on an event stage (e.g. user.created).
400 invalid_fail_modefailMode is not open or closed.
400 invalid_fieldtimeoutMs or maxRetries is out of range (the message carries the bound).
401 missing_tnt / invalid_tnt / missing_subThe access token lacks a valid tnt or sub claim.
404 not_foundThe hook is missing, belongs to another tenant, or lives in the other test/live mode.
409 hook_existsA hook already exists for that stage (within your mode).
409 idempotency_conflictA request with the same Idempotency-Key is already in progress.
409 rotation_in_flightA signing rotation overlap is still open for this hook.
409 version_conflictThe hook was modified concurrently; retry.
502 vault_unavailableSecret storage is temporarily unavailable; retry.
502 hook_executor_unavailableOn 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 401 on 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 tnt claim, and cross-tenant (or cross-mode) access returns 404, never 403.
  • 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.