Stytch B2B webhook
This page is auto-generated from
src/triggers/stytch-webhook.ts. Do not edit by hand — seedocs/dev/docs-auto-sync.md.
Inbound webhook from Stytch — organization / member / session / SSO state changes.
| Method | Path |
|---|---|
POST | /webhooks/stytch |
Signature verification
Section titled “Signature verification”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.
Idempotency
Section titled “Idempotency”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
Section titled “Retry posture”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.
Request body
Section titled “Request body”StytchWebhookEnvelope
Section titled “StytchWebhookEnvelope”| Field | Type | Optional |
|---|---|---|
event_id | string | yes |
source | string | yes |
object_type | string | yes |
action | string | yes |
organization_id | string | yes |
organization | { organization_id?: string } | yes |
Dispatch tables
Section titled “Dispatch tables”KNOWN_NOOP_EVENTS (Set)
Section titled “KNOWN_NOOP_EVENTS (Set)”organization.createorganization.updatemember.createmember.updatemember.deletemember.role_assignedmember.role_revokedsession.revokedsaml_connection.createsaml_connection.updatesaml_connection.deleteoidc_connection.createoidc_connection.updateoidc_connection.deletescim_connection.createscim_connection.updatescim_connection.delete
Architecture preamble
Section titled “Architecture preamble”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 changesupstream. 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 staysqueryable while a fresh Stytch org is wired up. Every other documentedStytch event is parsed, logged, and 200'd; the per-event decision matrixlives 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 bySvix (see docs/auth/stytch-setup.md §8). Svix uses HMAC-SHA256 over`${webhook-id}.${webhook-timestamp}.${rawBody}` with the secretbase64-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 — thescheme is intricate enough (multi-version sig support, replay window)that a bespoke verifier would be a foot-gun. See the source: theinstruction in #378 explicitly allows using the SDK helper here.
Why this lives in src/triggers/ (not src/auth/): the file is aroute+verify+dispatch surface, structurally the same shape assrc/triggers/webhook.ts (the customer-publish webhook) andsrc/blocklist/webhook.ts (GHSA real-time path). The unbind ACTIONlives in src/auth/stytch-tenant-binding.ts where the rest of thetenant-binding read/write seam lives. Same separation the triggerswebhook 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 mountthis route. Both deployment modes have DATABASE_URL + REDIS_URL via`vanguard-data-urls` (Path A, #341), so unbind + idempotency work ineither mode.