> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firstflow.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> How FirstFlow delivers flow events to your own HTTP endpoints signed, queued through an outbox, and retried with exponential backoff.

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](/concepts/flows-and-nodes); for the closely related node that
reads a response back into the flow, see [API calls](/integrations/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.

```mermaid theme={null}
flowchart LR
  A[Flow node / logic action / SDK call] -->|dispatch| B[(WebhookOutbox<br/>status: pending)]
  B -->|cron, every minute<br/>claim 50 due rows| C{HTTP POST<br/>signed, 10s timeout}
  C -->|2xx| D[status: delivered]
  C -->|non-2xx or timeout| E{attempt &lt; maxAttempts?}
  E -->|yes| F[status: retrying<br/>backoff 2^attempts s]
  F -->|nextAttemptAt elapses| C
  E -->|no| G[status: dead]
  G -.->|POST /webhooks/outbox/:id/retry| B

  classDef src fill:#fde68a,stroke:#b45309,color:#1c1917
  classDef store fill:#bbf7d0,stroke:#15803d,color:#1c1917
  classDef ok fill:#a7f3d0,stroke:#047857,color:#1c1917
  classDef bad fill:#fecaca,stroke:#b91c1c,color:#1c1917
  class A src
  class B,F store
  class D,C ok
  class G,E bad
```

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:

```
x-firstflow-webhook-signature: v1,<hex hmac of `${timestamp}.${body}`>
```

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:

| Header                          | Meaning                                                                               |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| `x-firstflow-webhook-id`        | Stable event id. Same id across all retries of one event use it to dedupe.            |
| `x-firstflow-webhook-event`     | Event type, e.g. `experience.flow_node.webhook` or `experience.logic_action.webhook`. |
| `x-firstflow-webhook-timestamp` | Unix seconds at signing time; part of the signed material.                            |
| `x-firstflow-webhook-signature` | `v1,<hmac>` present only when a secret is configured.                                 |
| `user-agent`                    | Always `Firstflow-Webhooks/1`.                                                        |

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.

```ts theme={null}
import { createHmac, timingSafeEqual } from "crypto";

const TOLERANCE_SECONDS = 5 * 60;

// `rawBody` is the exact request body string. `WEBHOOK_SECRET` is the secret
// you configured on the Webhook node / logic action.
function verify(rawBody: string, headers: Record<string, string>): boolean {
  const timestamp = headers["x-firstflow-webhook-timestamp"];
  const header = headers["x-firstflow-webhook-signature"]; // "v1,<hex>"
  if (!timestamp || !header) return false;

  // Reject replays: drop deliveries whose timestamp drifts too far from now.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const [version, candidate] = header.split(",");
  if (version !== "v1" || !candidate) return false;

  const a = Buffer.from(candidate);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

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`:

```json theme={null}
{
  "type": "experience.logic_action.webhook",
  "version": 1,
  "data": {
    "entityId": "exp_…",
    "occurredAt": "2026-06-08T12:00:00.000Z",
    "agentId": "agt_…",
    "experienceId": "exp_…",
    "source": "node_trigger",
    "actionIndex": 0
  }
}
```

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:

<ParamField path="webhookUrl" type="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.
</ParamField>

<ParamField path="webhookMethod" type="string" default="POST">
  HTTP method. `GET` and `HEAD` are sent without a body; everything else carries
  the JSON body.
</ParamField>

<ParamField path="webhookSecret" type="string">
  HMAC signing secret. When set, the delivery carries
  `x-firstflow-webhook-signature`. Omit it for unsigned delivery.
</ParamField>

<ParamField path="webhookHeaders" type="Array<{ key, value }>">
  Custom headers. Each `value` is interpolated for `{{userId}}` and `{{content}}`
  before sending. Headers without a key are dropped.
</ParamField>

<ParamField path="webhookBody" type="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.
</ParamField>

<ParamField path="webhookRetryOnFailure" type="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.
</ParamField>

<ParamField path="webhookContinueOnFailure" type="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.
</ParamField>

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](/concepts/flows-and-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.

```ts theme={null}
const res = await fetch(
  `https://api.firstflow.app/v1/agents/${agentId}/experiences/${experienceId}/logic-webhook`,
  {
    method: "POST",
    headers: {
      authorization: `Bearer ${token}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      idempotencyKey: "order-4187-provisioned", // your stable key, ≤ 200 chars
      source: "node_trigger",                   // or "experience_trigger"
      nodeId: "node_…",                         // required for node_trigger
      nodeTriggerGroupId: "grp_…",              // required for node_trigger
      actionIndex: 0,                           // which action in the group
    }),
  },
);

// 202 Accepted on first call → row enqueued for delivery
// 204 No Content on a duplicate idempotencyKey → nothing enqueued
```

The fields are exact. `source` selects where the webhook action lives:

| `source`             | Locates the action by                      | Required fields                                                                             |
| -------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `experience_trigger` | `settings.triggers[].actions[actionIndex]` | `triggerRowId`, `actionIndex`                                                               |
| `node_trigger`       | a flow node's trigger group                | `nodeId`, `nodeTriggerGroupId`, `actionIndex` (+ `questionId` for survey-question triggers) |

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.

<Accordion title="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.
</Accordion>

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](/integrations/slack), or read an HTTP response back into a
flow with [API calls](/integrations/api-calls). For the node model that hosts all
of these, see [Flows & nodes](/concepts/flows-and-nodes).
