Skip to main content
A webhook lets a flow reach out to your backend the moment something happens inside an experience to record a survey answer, kick off a provisioning job, or notify an internal system. Instead of POSTing inline and hoping your endpoint is up, FirstFlow writes the request to a durable outbox and a background worker delivers it, signs it, and retries until it succeeds or exhausts its attempts. This page explains the delivery mechanism end to end, the exact signature and headers you verify, and the three ways a webhook gets enqueued. For where the Webhook node sits among other side-effect nodes, see Flows & nodes; for the closely related node that reads a response back into the flow, see API calls.

How it works

Every outbound webhook follows the same path regardless of where it originates. The component that decides to fire (a flow node, a logic action, or an SDK call) does not make the HTTP request. It calls WebhookDispatchService.dispatch(), which writes one row to the WebhookOutbox table with status pending and a nextAttemptAt of now. A worker on a one-minute cron claims due rows, signs each one, POSTs it, and updates its status. Delivery is therefore asynchronous: the flow continues immediately, and the request leaves within roughly a minute. The worker (WebhookDispatchWorker) runs dispatchPending() once a minute. It claims up to 50 rows whose status is pending or retrying and whose nextAttemptAt has elapsed, ordered oldest-first, then delivers them one by one. A guard flag prevents two cron ticks from overlapping, so a slow batch never runs concurrently with the next tick. Each delivery is a single fetch with a 10-second timeout. The destination must answer 2xx to count as delivered any non-2xx response, a network error, or a timeout is a failure. On success the row is marked delivered. On failure the worker compares the attempt number to the row’s maxAttempts: if attempts remain, the row goes back to retrying with a future nextAttemptAt; once the cap is hit, the row is marked dead and stops being claimed.

The signature you verify

When the enqueuing action supplies a signing secret, the worker adds an HMAC so your endpoint can prove the request came from FirstFlow and is not a replay. The signature covers the timestamp joined to the raw body `${timestamp}.${body}` — hashed with HMAC-SHA256 and hex-encoded, prefixed with the scheme version:
Because the timestamp is part of the signed material and is also sent as its own header, you can reject stale requests instead of accepting a captured one forever. Every delivery also carries: The id is derived from the event type, the entity, and the payload, so it is stable for a given event and identical across retries. Treat your handler as idempotent: persist on first sight of an id and ignore repeats.

Verifying a delivery

Compute the same HMAC over the raw, unparsed body and compare it in constant time. Read the body as bytes before any JSON middleware reshapes it re-encoding a parsed object will not reproduce the signed string.
The five-minute tolerance is a convention, not enforced by FirstFlow on the outbound side pick a window that fits your clock skew. Always compare with timingSafeEqual (or your platform’s constant-time equality); a plain === leaks timing information about how many leading bytes matched.

What the body looks like

There are two body shapes, and which one you receive depends on how the webhook was enqueued. The default is a versioned envelope used by logic-action and SDK-enqueued webhooks. FirstFlow wraps your data so you can branch on type and version:
The Webhook flow node instead sends your custom JSON body verbatim — there is no envelope, because you author the body yourself in the node editor. FirstFlow only interpolates template tokens (below) and POSTs the result as-is. Design your receiver around whichever path you use; do not assume an envelope on a flow-node webhook.

The Webhook flow node

Add a Webhook node to a flow and configure its destination. When the flow reaches the node, the runtime walker builds the request from the node’s data.config and hands it to the dispatcher. The relevant config fields are:
webhookUrl
string
required
Destination URL. Validated against SSRF before enqueue private and loopback hosts are blocked in production (relaxed in development so http://localhost works). A missing or blocked URL skips the node.
webhookMethod
string
default:"POST"
HTTP method. GET and HEAD are sent without a body; everything else carries the JSON body.
webhookSecret
string
HMAC signing secret. When set, the delivery carries x-firstflow-webhook-signature. Omit it for unsigned delivery.
webhookHeaders
Array<{ key, value }>
Custom headers. Each value is interpolated for {{userId}} and {{content}} before sending. Headers without a key are dropped.
webhookBody
string (JSON)
The request body, authored as JSON. Bare {{var}} tokens are quoted so the body still parses, then string leaves are interpolated. An invalid JSON body skips the node it is never sent half-formed.
webhookRetryOnFailure
boolean
default:"false"
When on, the row gets 4 delivery attempts; when off, 1 (deliver once, no retry). This is the per-node cap, distinct from the queue’s default of 5.
webhookContinueOnFailure
boolean
default:"true"
When the node can’t be queued (missing or blocked URL, invalid JSON body) and this is explicitly off, the flow walk halts at this node. On (the default), the walk continues regardless.
Template interpolation is deliberately small: only {{userId}} and {{content}} are substituted, in both header values and the JSON body. The body is quoted first so {"id": {{userId}}} becomes valid JSON before the value is injected — a template value can never break the body’s syntax. A node-fired webhook is sent as experience.flow_node.webhook with your webhookBody as the verbatim request body. Whether it fires the instant the flow reaches it or waits until the user advances depends on flow pacing: in a client-paced multi-step flow, action nodes are deferred and fire when the user actually reaches that step (driven server-side by flow.next), not at compose time. See Flows & nodes.

Enqueuing from the SDK (logic webhooks)

Experiences can also enqueue a webhook programmatically against a configured webhook action, via POST /v1/agents/:agentId/experiences/:id/logic-webhook. This targets a webhook action you already defined on an experience trigger row or on a flow node trigger group and fires it on demand. The endpoint is idempotent: the first call with a given idempotencyKey is accepted, and duplicates within the dedupe window are dropped.
The fields are exact. source selects where the webhook action lives: The server resolves the referenced action, reads its url / method / headers / secret, and only then enqueues so the destination is never client-supplied. A 202 means the row was written to the outbox; it does not mean your endpoint received it yet. A 204 means a row with that idempotencyKey already exists and nothing new was enqueued. Validation is strict: a missing idempotencyKey, a negative actionIndex, or a path that resolves to no webhook action returns 400. The enqueue endpoint is rate-limited to 60 calls per minute per agent.

Delivery semantics and limits

Retry backoff grows with each attempt and is capped. After attempt n fails, the next attempt is scheduled min(300, max(5, 2^n)) seconds out so the gaps widen 5s, 5s, 8s, 16s, 32s… and never exceed five minutes. Because the worker ticks once a minute, the practical floor between attempts is roughly one minute regardless of the computed backoff. The maximum attempt count depends on the source. The queue default is 5 (capped at 10). The Webhook flow node overrides this with its webhookRetryOnFailure option: on → 4 attempts, off → 1. A row that exhausts its attempts is marked dead and is no longer claimed.
A dead row can be requeued with POST /v1/webhooks/outbox/:id/retry. It flips the row back to pending with nextAttemptAt of now, so the next worker tick picks it up. The response carries { data: { id, requeued } } requeued is false (with a 404 status code) if no dead row with that id exists. This is the manual escape hatch when an endpoint was down past the retry window.
A few facts worth holding onto. Delivery is at-least-once, not exactly-once: a request that succeeds on your side but whose response is lost will be retried, so dedupe on x-firstflow-webhook-id. Ordering is not guaranteed rows are claimed oldest-first, but retries reschedule into the future, so a retried event can arrive after a later one. The signing secret lives only on the enqueuing action’s config; rotate it by updating the node or logic action, and update your receiver to accept both old and new during the cutover.

Inbound webhooks

FirstFlow exposes exactly one inbound webhook endpoint, and it is not for your application traffic: POST /webhooks/auth ingests Clerk auth-provider events (organization, user, and session lifecycle) in the managed product. It verifies the Svix signature scheme over `${id}.${timestamp}.${body}` against CLERK_WEBHOOK_SIGNING_SECRET, rejects timestamps outside a five-minute window, and dedupes on (provider, eventId) so a redelivered event is acknowledged without reprocessing. There is no general-purpose inbound webhook for sending arbitrary events into a flow flows are driven by conversation activity and the SDK, not by inbound HTTP. To run logic on demand from your backend, use the logic-webhook enqueue endpoint above.

Next

Reach Slack channels and DMs from a flow with the Slack integration, or read an HTTP response back into a flow with API calls. For the node model that hosts all of these, see Flows & nodes.