- Webhooks
- Webhook Ingestion
Webhook Ingestion
Ironflow provides two ways to receive external webhooks (Stripe, GitHub, etc.) and transform them into native Ironflow events.
How It Works
Ironflow supports two ingress paths:
Path A — Server-managed source (register via Dashboard / CLI / RPC):
Provider (GitHub) │ POST /api/v1/webhooks/:provider ▼ ┌──────────────────────────────┐ │ webhook handler (Ironflow) │ │ auth per-source token │──▶ webhook_sources │ verify HMAC current | prev │ (config + secrets) │ dedup payload id │──▶ webhook_deliveries │ record delivery row │ (dedup index + 30d audit) └──────────────┬───────────────┘ ▼ eventtrigger.Helper.Emit event + outbox row, one tx ▼ match functions → create runs- Verification: If a
verify_configand averify_secretare configured on theWebhookSource, Ironflow validates the signature withsubtle.ConstantTimeCompare. The descriptor says which header carries the signature, what string gets signed, and how it is encoded — see Signature verification below. - Deduplication: Automatic idempotency using the descriptor’s
dedup_id_path— a header (header:X-GitHub-Delivery) or a dotted body path (body:data.object.id). Without a descriptor, the top-levelidorevent_idbody field is used. - Event name: Built server-side as
{event_prefix}.{type}, where the type comes from the descriptor’sevent_name_path— a header (header:X-GitHub-Event) or a body path (body:type). Anevent_prefixofstripewithbody:typeresolving topayment_intent.succeededproducesstripe.payment_intent.succeeded. The event’ssourcemetadata column is set towebhook. - Schema version: The emitted event carries the source’s
schema_version, which defaults to1. A third-party sender cannot express an Ironflow schema version, so it is set on the source by the operator who registered the schema rather than read out of the (untrusted) payload. One version covers every event name the source emits — a Stripe source pinsstripe.charge.refundedandstripe.payment_intent.succeededalike. It matters only when event-schema enforcement is on (#1955). - Execution: The resulting event triggers matching functions.
Signature verification
Path A verifies against a signature descriptor on the source (ADR 0049). Providers differ along a handful of axes — which header, what string gets signed, hex or base64, where the event type and delivery ID live — and the descriptor expresses all of them as configuration.
Nothing in Ironflow branches on a provider name. The dashboard offers presets for GitHub, Stripe, Shopify, Slack and Standard Webhooks, but picking one only fills the fields; every field stays editable. A provider Ironflow has never heard of works the same way, and a provider that changes its scheme is an edit rather than an upgrade.
| Field | What it says | Stripe example |
|---|---|---|
signature_header | Header carrying the signature | Stripe-Signature |
entry_separator | Splits a multi-entry header (blank = one entry) | , |
kv_delimiter | Splits key=value (blank = bare signature) | = |
signature_key | Which entries hold signatures | v1 |
timestamp_header | Separate timestamp header | — |
timestamp_key | Or the key inside the signature header | t |
signing_template | What gets signed: {body}, {ts}, {id} | {ts}.{body} |
encoding | hex or base64 | hex |
algorithm | hmac-sha256 or hmac-sha1 | hmac-sha256 |
tolerance_seconds | Replay window | 300 |
event_name_path | header:<Name> or body:<dotted.path> | body:type |
dedup_id_path | Same syntax; used for idempotency | body:id |
Every signature in the header is tried, not just the first. Stripe and Standard Webhooks both emit several while a secret rotates, so matching only the first would work in steady state and fail exactly during a rotation.
Replay tolerance only applies when the timestamp is signed
tolerance_seconds is honored only when signing_template contains {ts}.
Stripe and Slack fold the timestamp into the signed string, so it cannot be
altered without breaking the signature — that is what makes a freshness check
worth anything. Setting a tolerance without {ts} is rejected, not
silently ignored: the value would be attacker-controlled and the check would
protect nothing.
Testing a descriptor
A wrong signing template and a wrong secret both surface as invalid signature.
Dashboard → Webhooks → (source) → Test a delivery tells them apart: paste a
real body and its headers, and it shows the signing string the server built next
to the signature it computed. If the signing string matches the provider’s
documented shape, the template is right and the secret is not.
Nothing is persisted and no event is emitted.
Providers not covered
Twilio signs the request URL plus alphabetically sorted POST parameters,
which no template over the body can express, and it sends
application/x-www-form-urlencoded rather than JSON. Use Path B for Twilio.
Ed25519 (Discord, and the asymmetric Standard Webhooks variant) is not yet
supported — it verifies against a public key rather than a shared secret, which
does not fit the verify_secret rotation model. Use Path B.
Legacy sources
Sources configured before this feature — using verify_header and
verify_algorithm — keep working unchanged. Ironflow synthesizes an equivalent
descriptor on read (hex HMAC over the bare body), so no migration is required.
Editing such a source in the dashboard writes an explicit descriptor.
Authenticating the ingest request
Path A requires a credential on the ingest request, which providers cannot send as a header — so it goes in the URL. Sources created after migration 046 carry a per-source ingest token (ADR 0048): append ?token=ifwh_... and no org API key is involved. The token is shown once, at create and at rotate. Sources predating that migration still authenticate with an API key (?token=ifkey_...) until you issue them a token.
The token answers “may you post here”; the signature answers “did this come from who it claims”. Both are worth having and neither substitutes for the other.
Path B — SDK-managed webhook (createWebhook + serve()):
Provider (Stripe) │ POST https://your-app.com/.../webhooks/stripe ▼ ┌──────────────────────────────┐ │ your app (SDK) │ your process, your code │ verify() │ │ transform() → event │ └──────────────┬───────────────┘ │ POST /ironflow.v1.IronflowService/Emit ▼ ┌──────────────────────────────┐ │ events handler (Ironflow) │ no webhook_sources, │ eventtrigger.Helper.Emit │ no webhook_deliveries └──────────────┬───────────────┘ ▼ match functions → create runsThe JS SDK mounts its handler on any URL path that contains /webhooks/:provider (regex /\/webhooks\/([^/]+)/); the Go SDK matches it as a path prefix instead. Your verify() runs locally; your transform() produces { name, data, idempotencyKey }; the SDK forwards it to Ironflow’s /ironflow.v1.IronflowService/Emit endpoint. The Quick Start below uses Path B.
Quick Start
1. Define the webhook source:
import { createWebhook } from "@ironflow/node";
const stripeWebhook = createWebhook({ id: "stripe", // Verification logic (HMAC, etc.) verify: (req) => { /* ... */ }, // Transform payload into Ironflow event transform: (payload) => ({ name: `stripe.${payload.type}`, data: payload.data.object, idempotencyKey: payload.id, }),});2. Register with the server:
import { serve } from "@ironflow/node";
export const POST = serve({ webhooks: [stripeWebhook], functions: [myWorkflow], // Required for webhooks: where transformed events are POSTed. Falls back to // the IRONFLOW_URL env var. With neither set, the handler still answers // 200 `accepted` but silently emits nothing. serverUrl: "http://localhost:9123",});Outside dev mode /ironflow.v1.IronflowService/Emit is not a public route, so also set IRONFLOW_API_KEY — the SDK sends it as the emit’s bearer token, and an unauthenticated emit fails the whole webhook with EMIT_FAILED (502).
3. Point your provider URL:
Any path on your app that ends with /webhooks/stripe works — the SDK matches /webhooks/:provider anywhere in the URL. For a Next.js Route Handler at app/api/webhooks/[provider]/route.ts, the URL is https://your-app.com/api/webhooks/stripe.
1. Define the webhook source:
import ( "encoding/json"
"github.com/sahina/ironflow-go/ironflow")
stripeWebhook := ironflow.CreateWebhook(ironflow.WebhookConfig{ ID: "stripe", // Verification logic (HMAC, etc.) Verify: func(req *ironflow.WebhookRequest) error { /* ... */ return nil }, // Transform payload into Ironflow event Transform: func(payload []byte) (*ironflow.WebhookEvent, error) { var p struct { ID string `json:"id"` Type string `json:"type"` Data struct { Object json.RawMessage `json:"object"` } `json:"data"` } if err := json.Unmarshal(payload, &p); err != nil { return nil, err } return &ironflow.WebhookEvent{ Name: "stripe." + p.Type, Data: p.Data.Object, IdempotencyKey: p.ID, }, nil },})2. Register with the server:
handler := ironflow.Serve(ironflow.ServeConfig{ Webhooks: []ironflow.Webhook{stripeWebhook}, Functions: []ironflow.Function{myWorkflow}, // Required for webhooks: where transformed events are POSTed. ServerURL: "http://localhost:9123",})
http.Handle("/webhooks/", handler)http.ListenAndServe(":3000", nil)3. Point your provider URL:
The Go handler matches /webhooks/:provider as a path prefix (not anywhere in the URL, unlike the JS SDK). Mounted as above, the URL is https://your-app.com/webhooks/stripe. To serve it under a subpath, strip the prefix first: http.Handle("/api/", http.StripPrefix("/api", handler)).
Not available. createWebhook is worker-runtime code — it mounts a handler
that Ironflow’s serve() dispatches to — and the Python SDK ships no worker
runtime. It also has no methods for the server-managed webhook source API
(create / list / delete sources, list deliveries).
Two options for a Python app:
- Dashboard or CLI registration (Path A) — register the source server-side
via
Dashboard → Webhooks → New Sourceorironflow webhook, and let the server verify and convert the payload into an event. No SDK involved. - Verify it yourself — accept the provider’s POST in your own web
framework, check the signature, and forward the result with
rpc.events.emit(TriggerRequest(event=..., data=...)).
See the SDK comparison matrix for the full Python surface.
Registration Models
| Model | Setup | Use Case |
|---|---|---|
| Dashboard | Dashboard → Webhooks → New Source | Add a server-managed source via UI (provider ID, event prefix, optional verify header/algorithm/secret). |
| CLI | ironflow webhook list, ironflow webhook deliveries, ironflow webhook test | List server-managed sources, browse deliveries, and POST a test payload to /api/v1/webhooks/:provider. Pass --token ifwh_... for sources that carry an ingest token. |
| SDK (Code) | createWebhook({ ... }) + serve({ webhooks }) | Custom verification or payload mapping that lives next to your application code (Path B). |
Managing Sources
Path A sources support live edits without losing delivery history. Four endpoints split the surface so secret rotation, lifecycle changes, and configuration edits stay separately auditable:
| Endpoint | Edits | Notes |
|---|---|---|
UpdateWebhookSource | name, verify_header, verify_algorithm, verify_config, metadata, schema_version | event_prefix, source_type, and id are immutable post-create. schema_version is deliberately not: recreating a source to migrate its schema version would mint a new ID and therefore a new ingest URL, turning a schema migration into a config change in the provider’s dashboard. It is preserve-on-omit like verify_config, so a rename cannot silently reset a migrated source back to version 1. verify_secret is not touched — use rotate. name and metadata are full-replace; verify_header, verify_algorithm and verify_config are preserve-on-omit, so a rename cannot silently downgrade verification (before that fix, a rename on a descriptor-less source cleared the legacy pair and the ingest path stopped checking signatures while verify_secret_set still read true). Send expected_updated_at (the updated_at you last read) to get optimistic concurrency: the write is rejected with ABORTED if the row moved, which is what stops a rename from reverting a concurrent descriptor change. |
RotateWebhookSecret | verify_secret, grace_seconds | New secret is required. Promotes the prior current secret to a prev slot for the grace window. |
DisableWebhookSignatureVerification | grace_seconds | Clears the current secret; preserves the prior secret as prev for the grace window. Source operates unsigned after the window. |
ExpireWebhookSecretPrev | (none) | Force-expires prev immediately. Idempotent. |
Rotation lifecycle (dual-secret with grace window — ADR 0024). Every rotation and disable carries a grace window during which the prior secret stays valid. The verify path tries the current secret first; on mismatch it falls back to the previous secret if it has not expired. Providers retrying with the old signature continue to land in webhook_deliveries.status = accepted until the grace window ends.
Grace window options (dashboard dropdown; same values accepted via SDK):
| Option | grace_seconds |
|---|---|
| Immediate (no grace) | 0 |
| 1 hour | 3600 |
| 24 hours (default) | 86400 |
| 7 days (cap) | 604800 |
Values above 604800 are rejected with InvalidArgument. The default can be overridden at the cluster level with the IRONFLOW_WEBHOOK_SECRET_GRACE_HOURS_DEFAULT env var (integer hours, also capped at 168 / 7 d — boot-time fatal if exceeded).
The WebhookSource response carries:
verify_secret_set bool— true when the current slot is configured.verify_secret_prev_set bool— true when a previous secret is still set (the raw value is never returned).verify_secret_prev_expires_at— timestamp when the grace window ends. Combine withprev_setto determine whether the window is active.
Dashboard: Open Webhooks → {source}. The verification card surfaces a Rotate and a Disable verification button. Both open a dialog with a grace-window dropdown. While a window is active, a banner displays the countdown and a Force expire now button.
SDK: all four are wrapped in the Go SDK and in @ironflow/node / @ironflow/browser (#1526). The Python SDK is generated from the REST route manifest and reaches no ConnectRPC method, so it has no webhook surface at all — use the Dashboard or another SDK.
// Edit: `name` and `metadata` are full-replace — fetch first, then submit the// full intended state.const current = await client.webhooks.getSource(sourceId);await client.webhooks.updateSource({ id: current.id, name: "GitHub production", verifyHeader: current.verifyHeader, // preserve verifyAlgorithm: current.verifyAlgorithm, // preserve metadata: current.metadata, // preserve expectedUpdatedAt: current.updatedAt, // reject the write if the row moved});
// Rotate with the server's default grace (omit graceSeconds entirely — passing// 86400 would override IRONFLOW_WEBHOOK_SECRET_GRACE_HOURS_DEFAULT).await client.webhooks.rotateSecret({ id: sourceId, verifySecret: "ghsec_new" });
// Rotate with a 1 h grace.await client.webhooks.rotateSecret({ id: sourceId, verifySecret: "ghsec_new", graceSeconds: 3600,});
// Instant cutover. `0` is a real value here, not "unset".await client.webhooks.rotateSecret({ id: sourceId, verifySecret: "ghsec_new", graceSeconds: 0,});
// Force-expire the previous secret slot.await client.webhooks.expireSecretPrev(sourceId);
// Disable signature verification with the default grace.await client.webhooks.disableSignatureVerification({ id: sourceId });// Edit: `name` and `metadata` are full-replace — fetch first, then submit the// full intended state.current, err := client.Webhooks().GetSource(ctx, sourceID)if err != nil { /* ... */ }_, err = client.Webhooks().UpdateSource(ctx, ironflow.UpdateWebhookSourceInput{ ID: current.ID, Name: "GitHub production", VerifyHeader: current.VerifyHeader, VerifyAlgorithm: current.VerifyAlgorithm, Metadata: current.Metadata, ExpectedUpdatedAt: current.UpdatedAt, // reject the write if the row moved})
// Rotate with the default 24 h grace._, err = client.Webhooks().RotateSecret(ctx, ironflow.RotateWebhookSecretInput{ ID: sourceID, VerifySecret: "ghsec_new",})
// Rotate with a 1 h grace.grace := time.Hour_, err = client.Webhooks().RotateSecret(ctx, ironflow.RotateWebhookSecretInput{ ID: sourceID, VerifySecret: "ghsec_new", GracePeriod: &grace,})
// Instant cutover (matches the pre-#997 behavior).zero := time.Duration(0)_, err = client.Webhooks().RotateSecret(ctx, ironflow.RotateWebhookSecretInput{ ID: sourceID, VerifySecret: "ghsec_new", GracePeriod: &zero,})
// Force-expire the previous secret slot._, err = client.Webhooks().ExpireSecretPrev(ctx, sourceID)
// Disable signature verification with a 24 h grace._, err = client.Webhooks().DisableSignatureVerification(ctx, sourceID, nil)Not available. ironflow.client is generated from the REST route manifest and
reaches no ConnectRPC method, so it carries no webhook surface at all — not
just these four. Use the Dashboard, the Go SDK, or @ironflow/node.
Monitoring & Audit
Ironflow records every Path A delivery in the webhook_deliveries table and retains rows for 30 days (hardcoded — a background sweep runs hourly). In the Dashboard → Webhooks section, you can:
- Browse all registered webhook sources and create/delete them from the UI.
- Open the Deliveries view to see status (
accepted,deduplicated,rejected,failed), external ID, timestamp, and linked event ID. - Expand a delivery row to inspect raw request headers and body.
Path B deliveries are not written to webhook_deliveries — they emit straight to /ironflow.v1.IronflowService/Emit, so they appear in the Events stream rather than the Deliveries view.
Idempotency
On Path A, Ironflow deduplicates by the descriptor’s dedup_id_path — a header or a dotted body path (composite unique index on (source_id, external_id)). Without a descriptor it falls back to the top-level id or event_id body field. If neither resolves, no dedup occurs, which is why GitHub and Shopify sources need dedup_id_path set: both put the delivery ID in a header the body-only fallback cannot see.
Path B forwards the provider idempotency key. Both SDK serve handlers send the transformed event through IronflowService/Emit, which deduplicates matching keys. Return a stable provider event ID from transform().