Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Webhook Ingestion

Ironflow provides two ways to receive external webhooks (Stripe, GitHub, etc.) and transform them into native Ironflow events.

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
  1. Verification: If a verify_config and a verify_secret are configured on the WebhookSource, Ironflow validates the signature with subtle.ConstantTimeCompare. The descriptor says which header carries the signature, what string gets signed, and how it is encoded — see Signature verification below.
  2. 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-level id or event_id body field is used.
  3. Event name: Built server-side as {event_prefix}.{type}, where the type comes from the descriptor’s event_name_path — a header (header:X-GitHub-Event) or a body path (body:type). An event_prefix of stripe with body:type resolving to payment_intent.succeeded produces stripe.payment_intent.succeeded. Ironflow rejects an event_prefix under ironflow. or $, and the prefix ironflow alone. See Reserved names. The event’s source metadata column is set to webhook, and its emitted_by to webhook:<source id>.
  4. Event data: The event data is the body’s top-level data field when it has one, and the whole body otherwise. The body must be a JSON object, or the delivery is refused with 400.
  5. Schema version: The emitted event carries the source’s schema_version, which defaults to 1. 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 pins stripe.charge.refunded and stripe.payment_intent.succeeded alike. It matters only when event-schema enforcement is on (#1955).
  6. Authorization: When policy enforcement is on, Ironflow checks the emit as events:emit for the webhook source, and a Rego rule can read the source ID from input.subject.webhook_source_id. A denied delivery gets 403 and a rejected delivery record. If the policy set cannot be evaluated, the delivery gets 503 and a failed record, so the provider retries. See Restrict who can emit an event.
  7. Execution: The resulting event triggers matching functions.

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.

FieldWhat it saysStripe example
signature_headerHeader carrying the signatureStripe-Signature
entry_separatorSplits a multi-entry header (blank = one entry),
kv_delimiterSplits key=value (blank = bare signature)=
signature_keyWhich entries hold signaturesv1
timestamp_headerSeparate timestamp header—
timestamp_keyOr the key inside the signature headert
signing_templateWhat gets signed: {body}, {ts}, {id}{ts}.{body}
encodinghex or base64hex
algorithmhmac-sha256 or hmac-sha1hmac-sha256
tolerance_secondsReplay window300
event_name_pathheader:<Name> or body:<dotted.path>body:type
dedup_id_pathSame syntax; used for idempotencybody: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.

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.

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.

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.

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 runs

The 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.


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.


ModelSetupUse Case
DashboardDashboard → Webhooks → Add SourceAdd 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 / SDKWebhookService.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.
CLIironflow webhook list, ironflow webhook deliveries, ironflow webhook testList 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).

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:

EndpointEditsNotes
UpdateWebhookSourcename, verify_header, verify_algorithm, verify_config, metadata, schema_versionevent_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.
RotateWebhookSecretverify_secret, grace_secondsNew secret is required. Promotes the prior current secret to a prev slot for the grace window.
DisableWebhookSignatureVerificationgrace_secondsClears 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.
RotateWebhookIngestTokeningest_tokenReplaces 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):

Optiongrace_seconds
Immediate (no grace)0
1 hour3600
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 with prev_set to 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 });

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.