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 callsWebhookDispatchService.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:
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.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 ontype and version:
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’sdata.config and
hands it to the dispatcher. The relevant config fields are:
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.HTTP method.
GET and HEAD are sent without a body; everything else carries
the JSON body.HMAC signing secret. When set, the delivery carries
x-firstflow-webhook-signature. Omit it for unsigned delivery.Custom headers. Each
value is interpolated for {{userId}} and {{content}}
before sending. Headers without a key are dropped.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.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.
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.
{{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, viaPOST /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.
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 scheduledmin(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.
Recovering a dead delivery
Recovering a dead delivery
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.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.