- Events & Pub/Sub
- 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
Section titled “How It Works”Ironflow supports two ingress paths:
Path A — Server-managed source (register via the Dashboard or the WebhookService 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. Ironflow rejects anevent_prefixunderironflow.or$, and the prefixironflowalone. See Reserved names. The event’ssourcemetadata column is set towebhook, and itsemitted_bytowebhook:<source id>. - Event data: The event data is the body’s top-level
datafield when it has one, and the whole body otherwise. The body must be a JSON object, or the delivery is refused with400. - 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). - Authorization: When policy enforcement is on, Ironflow checks the emit as
events:emitfor the webhook source, and a Rego rule can read the source ID frominput.subject.webhook_source_id. A denied delivery gets403and arejecteddelivery record. If the policy set cannot be evaluated, the delivery gets503and afailedrecord, so the provider retries. See Restrict who can emit an event. - Execution: The resulting event triggers matching functions.
Signature verification
Section titled “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.
Testing a descriptor
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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 // IRONFLOW_SERVER_URL. With none 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)).
1. Define the webhook source:
import json
from ironflow.serve import Webhook, WebhookEvent, WebhookRequest
def verify(req: WebhookRequest) -> None: ... # Verification logic (HMAC, etc.). Raise to reject the request.
def transform(payload: bytes) -> WebhookEvent: # Transform payload into Ironflow event p = json.loads(payload) return WebhookEvent(name=f"stripe.{p['type']}", data=p["data"]["object"], idempotency_key=p["id"])
stripe_webhook = Webhook(id="stripe", transform=transform, verify=verify)2. Register with the server:
from ironflow.serve import serve
app = serve( [my_workflow], webhooks=[stripe_webhook], # Where transformed events are sent. Defaults to IRONFLOW_SERVER_URL. server_url="http://localhost:9123",)Outside dev mode, also set IRONFLOW_API_KEY (or pass api_key=): the handler
sends it as the emit’s bearer token. Both verify and transform may be sync or
async.
3. Point your provider URL:
The ASGI app matches /webhooks/{id} from its own root. Run it with
uvicorn app:app and the URL is https://your-app.com/webhooks/stripe. Mounted
under FastAPI as api.mount("/ironflow", app), it is
https://your-app.com/ironflow/webhooks/stripe.
Registration Models
Section titled “Registration Models”| Model | Setup | Use Case |
|---|---|---|
| Dashboard | Dashboard → Webhooks → Add Source | Add a server-managed source via UI: pick a provider preset, then set a name (a display label; the server generates the source ID), the event prefix, the schema version, and optionally the secret and signature settings. The ingest token is shown once. |
| RPC / SDK | WebhookService.CreateWebhookSource, wrapped as client.webhooks.create (TS), client.Webhooks().CreateSource (Go), rpc.webhooks.create_source (Python) | Add a server-managed source from a script or CI. The response carries the raw ingest token once. |
| 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. The CLI cannot create a source. 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
Section titled “Managing Sources”Path A sources support live edits without losing delivery history. Five endpoints split the surface so secret rotation, token 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. |
RotateWebhookIngestToken | ingest_token | Replaces the per-source ingest token (ADR 0048) outright — no grace window, because the token lives only in the provider’s URL field and is replaced by the same operator who rotated it. The new raw token is returned once in ingest_token and is unrecoverable afterwards. Also takes expected_updated_at. Wrapped as webhooks.rotateIngestToken (TS) / Webhooks().RotateIngestToken (Go). |
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 five are wrapped in the Go SDK and in @ironflow/node / @ironflow/browser (#1526). In Python they are methods on IronflowRPC.webhooks.
// 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.updatedAtExact, // 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: ¤t.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)from ironflow import IronflowRPCfrom ironflow.rpc.v1 import ( DisableWebhookSignatureVerificationRequest, ExpireWebhookSecretPrevRequest, GetWebhookSourceRequest, RotateWebhookSecretRequest, UpdateWebhookSourceRequest,)
with IronflowRPC(server_url="http://localhost:9123", api_key="ifkey_...") as rpc: # Edit: `name` and `metadata` are full-replace — fetch first, then submit the # full intended state. current = rpc.webhooks.get_source(GetWebhookSourceRequest(id=source_id)) rpc.webhooks.update_source(UpdateWebhookSourceRequest( id=current.id, name="GitHub production", verify_header=current.verify_header, # preserve verify_algorithm=current.verify_algorithm, # preserve metadata=current.metadata, # preserve expected_updated_at=current.updated_at, # reject the write if the row moved ))
# Rotate with the server's default grace (omit grace_seconds). rpc.webhooks.rotate_secret(RotateWebhookSecretRequest(id=source_id, verify_secret="ghsec_new"))
# Rotate with a 1 h grace. rpc.webhooks.rotate_secret(RotateWebhookSecretRequest( id=source_id, verify_secret="ghsec_new", grace_seconds=3600, ))
# Instant cutover. `0` is a real value here, not "unset". rpc.webhooks.rotate_secret(RotateWebhookSecretRequest( id=source_id, verify_secret="ghsec_new", grace_seconds=0, ))
# Force-expire the previous secret slot. rpc.webhooks.expire_secret_prev(ExpireWebhookSecretPrevRequest(id=source_id))
# Disable signature verification with the default grace. rpc.webhooks.disable_signature_verification(DisableWebhookSignatureVerificationRequest(id=source_id))Monitoring & Audit
Section titled “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,rejected,failed), external ID, timestamp, and linked event ID. A deduplicated retry adds no row. - 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.