Skip to content

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
  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. The event’s source metadata column is set to webhook.
  4. 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).
  5. 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.

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.

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


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.


Registration Models

ModelSetupUse Case
DashboardDashboard → Webhooks → New SourceAdd a server-managed source via UI (provider ID, event prefix, optional verify header/algorithm/secret).
CLIironflow webhook list, ironflow webhook deliveries, ironflow webhook testList 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:

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.

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 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 });

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().