Skip to content

Stytch B2B webhook

This page is auto-generated from src/triggers/stytch-webhook.ts. Do not edit by hand — see docs/dev/docs-auto-sync.md.

Inbound webhook from Stytch — organization / member / session / SSO state changes.

MethodPath
POST/webhooks/stytch

Mechanism: Svix HMAC (Stytch webhook convention)

Transport: webhook-id + webhook-timestamp + webhook-signature headers (also accepts svix-* brand-prefixed variants for test-tier deliveries)

Svix-style HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<rawBody> with the base64-decoded secret (whsec_ prefix stripped). Includes a timestamp tolerance window for replay protection. Verified via the svix npm package’s Webhook.verify(payload, headers) rather than a hand-rolled verifier — the multi-version signature + replay-window logic is intricate enough that a bespoke implementation would be a foot-gun.

Atomic Redis SET NX EX keyed on the delivery’s event id. Dedup window: 1 day (86400 seconds). Concurrent retries cannot both pass the gate; a Redis outage fail-opens (one extra re-process is bounded by the action layer’s own idempotency).

Retry posture is governed by the upstream’s webhook delivery contract. Upwarden responds 2xx on accepted deliveries (including known-no-op events) so the upstream stops retrying; transient failures return 5xx so the upstream re-delivers within its retry budget.

FieldTypeOptional
event_idstringyes
sourcestringyes
object_typestringyes
actionstringyes
organization_idstringyes
organization{ organization_id?: string }yes
  • organization.create
  • organization.update
  • member.create
  • member.update
  • member.delete
  • member.role_assigned
  • member.role_revoked
  • session.revoked
  • saml_connection.create
  • saml_connection.update
  • saml_connection.delete
  • oidc_connection.create
  • oidc_connection.update
  • oidc_connection.delete
  • scim_connection.create
  • scim_connection.update
  • scim_connection.delete

The leading doc-comment block from the source file, reproduced verbatim. This is the canonical narrative explanation of the receiver — the auto-generated sections above are derived from it.

Stytch B2B webhook receiver — RBAC Phase 3 PR 4 (#378).
Stytch fires webhooks at us when org/member/session/SSO state changes
upstream. Today the only event we ACT on is `direct.organization.delete`
(and its `dashboard.*` / `scim.*` sources, which carry the same shape) —
that triggers an unbind of `tenants.stytch_org_id` so the tenant stays
queryable while a fresh Stytch org is wired up. Every other documented
Stytch event is parsed, logged, and 200'd; the per-event decision matrix
lives in #378's decisions table (linked from the issue).
Topology:
Stytch → POST https://vg-admin.<your-domain>/webhooks/stytch
↓ Svix signature verify (Stytch's webhook layer = Svix)
↓ idempotency check (Redis SET NX EX 86400)
↓ dispatch on `source.object_type.action`
↓ organization.delete → unbindStytchOrgFromTenant
↓ everything else → log + 200
↓ audit_log row on action paths (`system:stytch-webhook` principal)
Verification: Stytch documents that their webhook layer is powered by
Svix (see docs/auth/stytch-setup.md §8). Svix uses HMAC-SHA256 over
`${webhook-id}.${webhook-timestamp}.${rawBody}` with the secret
base64-decoded after stripping the `whsec_` prefix, plus a timestamp-
tolerance check for replay protection. We use the `svix` npm package's
`Webhook.verify(payload, headers)` rather than reimplementing — the
scheme is intricate enough (multi-version sig support, replay window)
that a bespoke verifier would be a foot-gun. See the source: the
instruction in #378 explicitly allows using the SDK helper here.
Why this lives in src/triggers/ (not src/auth/): the file is a
route+verify+dispatch surface, structurally the same shape as
src/triggers/webhook.ts (the customer-publish webhook) and
src/blocklist/webhook.ts (GHSA real-time path). The unbind ACTION
lives in src/auth/stytch-tenant-binding.ts where the rest of the
tenant-binding read/write seam lives. Same separation the triggers
webhook uses (route in /triggers/, action in /audit/+db/).
Decision #1 (locked): mount on the dashboard ingress
(`vg-admin.<your-domain>`), not on the engine's per-ecosystem hostnames.
Registration lives in src/server.ts under the `mountDashboard` branch
(combined + split-dashboard modes); split-engine pods do NOT mount
this route. Both deployment modes have DATABASE_URL + REDIS_URL via
`vanguard-data-urls` (Path A, #341), so unbind + idempotency work in
either mode.