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

# Workflow Execution Webhook

> Get a single POST to your server every time a workflow execution finishes, carrying the outcome and the full path the contact took through your workflow.

## What you get

Set a webhook URL on a workflow and Bolna sends your server **one request per execution**, at the moment that execution finishes. There is no request per node — the single payload carries the outcome plus the complete trail of every node the contact passed through, so you can reconstruct the whole journey without polling.

Every execution reports when it finishes, not just the ones that finish happily. A contact whose call never connected, a run you cancelled, and a run still in flight when you aborted its campaign all send a webhook, with a `status` and `termination_reason` that tell you which. Contacts still waiting to start when a campaign is aborted never begin an execution, so they send nothing.

<Note>
  This is separate from the [call status webhook](/docs/post-call/polling-call-status-webhooks), which fires per phone call. A workflow that places two calls sends two call webhooks and one execution webhook.
</Note>

***

## Configuring it

The webhook lives in the workflow's `settings`. Set it with the [Update Workflow API](/docs/api-reference/workflows/update):

<CodeGroup>
  ```bash request theme={"system"}
  curl -X PATCH https://api.bolna.ai/workflows/{workflow_id} \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
          "settings": {
            "webhook": {
              "url": "https://hooks.example.com/bolna/workflow",
              "headers": {"X-Api-Key": "your-receiver-token"}
            }
          }
        }'
  ```
</CodeGroup>

Setting a URL is what turns the webhook on. `headers` are sent with every delivery and are the usual way to authenticate the request as genuinely from Bolna.

A few rules the URL has to satisfy:

* **`https` only**, on port 443 or 8443.
* **No internal IP addresses.** A host that is a private, loopback, link-local or carrier-NAT IP address is rejected, including alternate spellings of one.
* **Redirects are not followed.** Point the URL at its final destination — a server that answers with a redirect is treated as not having received the webhook.

### Changing it later

`settings` is merged into what is already stored, so you only send what changes:

| To                                     | Send                                                                                                      |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Change the path, keeping the headers   | `{"settings": {"webhook": {"url": "https://hooks.example.com/v2"}}}`                                      |
| Move to another host, with its headers | `{"settings": {"webhook": {"url": "https://ingest.example.org/bolna", "headers": {"X-Api-Key": "..."}}}}` |
| Add or replace one header              | `{"settings": {"webhook": {"headers": {"X-Tenant": "acme"}}}}`                                            |
| Remove one header                      | `{"settings": {"webhook": {"headers": {"X-Tenant": null}}}}`                                              |
| Stop sending webhooks                  | `{"settings": {"webhook": null}}`                                                                         |

Moving the URL to a different host or port drops the stored headers, so a credential only ever goes to the host it was entered for. Send the headers again in the same request. Renaming a workflow never touches its webhook.

### Reading it back

`GET /workflows/{workflow_id}` returns the settings with every header value masked:

```json theme={"system"}
{
  "settings": {
    "webhook": {
      "url": "https://hooks.example.com/bolna/workflow",
      "headers": {"X-Api-Key": "**********"}
    }
  }
}
```

Header values are never returned. Because updates are merged, you never need them: to keep a header, leave it out of your update. Sending the mask `**********` back as a value is rejected.

***

## Testing it before you run anything

<Steps>
  <Step title="Publish the workflow">
    The sample is built from your published graph, so publish first.
  </Step>

  <Step title="Fire a test">
    Call the [Test Webhook API](/docs/api-reference/workflow-webhooks/test):

    ```bash theme={"system"}
    curl -X POST https://api.bolna.ai/workflows/{workflow_id}/webhook:test \
      -H "Authorization: Bearer YOUR_API_KEY"
    ```
  </Step>

  <Step title="Read the verdict">
    ```json theme={"system"}
    {
      "webhook_url": "https://hooks.example.com/bolna/workflow",
      "workflow_version": 3,
      "sample_path": ["n_start", "n_call1", "n_extract", "n_end"],
      "sample_outcome": "success",
      "result": {"delivered": true, "http_status": 200, "response_body": "ok"}
    }
    ```

    `delivered` is true only for a 2xx reply. `error_type: "http_error"` means your server was reached and replied with anything else, redirects included; `"unreachable"` means it could not be contacted at all.
  </Step>
</Steps>

The sample is not a generic fixture. It walks the first branch of each node in *your* published workflow, so it carries your node ids and your own extraction field names — whatever you write against the sample will match what you receive in production.

***

## The payload

```json theme={"system"}
{
  "event": "execution.terminal",
  "schema_version": 1,
  "execution_id": "exec:8f14e45f-...:CUST-9931",
  "workflow_id": "b6f1a2c4-...",
  "workflow_version": 3,
  "campaign_id": "8f14e45f-...",
  "reference_id": "CUST-9931",
  "status": "completed",
  "termination_reason": "promise kept",
  "outcome": "success",
  "occurred_at": "2026-09-22T11:02:41Z",
  "trail": [
    {
      "node_id": "n_call1",
      "node_type": "agent",
      "attempt": 2,
      "status": "completed",
      "to_node_id": "n_extract",
      "matched_case_index": 0,
      "inputs": {"recipient": "+919999999999"},
      "outputs": {"call.status": "completed", "call.duration_s": 148}
    }
  ]
}
```

| Field                | What it tells you                                                                                                                                                       |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reference_id`       | Your own identifier for the contact, exactly as you uploaded it. This is what you join on.                                                                              |
| `status`             | How the execution ended: `completed`, `failed`, `cancelled` or `aborted`.                                                                                               |
| `outcome`            | The end node's classification — `success`, `failure` or `neutral`. `null` when the execution never reached an end node.                                                 |
| `termination_reason` | The end node's label, or why it stopped without reaching one: `internal_error`, `required_variable_missing`, `cancelled`, `campaign_aborted`, `zombie` or `terminated`. |
| `occurred_at`        | When the execution finished. A resent webhook carries the same value.                                                                                                   |
| `trail`              | Every node attempt in order, with the branch each one took and the values it produced. Always sent whole, however long the journey.                                     |

Every field is also described on the [Execution webhook payload](/docs/api-reference/workflow-webhooks/execution-terminal) reference page.

### Reading the trail

Each entry is one attempt at one node. A node reached twice through a retry appears twice, with `attempt` 1 and 2. `to_node_id` is the node it moved to next, and `matched_case_index` is which of that node's branches matched — so the trail is the exact route through your workflow, not a summary of it.

`outputs` keys are namespaced by what produced them: `call.*` from an agent node, `extraction.*` for each disposition you selected, `response.*` from an API node, `whatsapp.*` from a WhatsApp node.

### Status combinations worth handling

| `status`    | `outcome`                         | What happened                                                                      |
| ----------- | --------------------------------- | ---------------------------------------------------------------------------------- |
| `completed` | `success` / `failure` / `neutral` | Reached an end node. The `outcome` is the one you gave that end node.              |
| `cancelled` | `null`                            | You cancelled the execution.                                                       |
| `failed`    | `null`                            | The execution could not continue — for example a required variable never resolved. |
| `aborted`   | `null`                            | The campaign was aborted while this execution was in flight.                       |

Reaching *any* end node produces `status: "completed"`, including one you labelled a failure. To separate genuine successes from handled failures, read `outcome`, not `status`.

***

## What to expect in delivery

<Warning>
  **Deliveries are not retried automatically.** If your endpoint returns a non-2xx status or times out after 10 seconds, that delivery is not attempted again on its own. Return a 2xx as soon as you have durably accepted the payload, and do slow work afterwards. To deliver a missed webhook, [resend it](#resending-a-webhook).
</Warning>

* **One delivery per execution.** A duplicate is not expected, but a resend delivers the same payload again, so key your handler on `execution_id`.
* **No ordering between executions.** Two executions finishing at the same moment can arrive in either order. Each payload is self-contained, so this only matters if you are sequencing across contacts yourself.
* **`execution_id` is stable** and is the same id the [execution APIs](/docs/api-reference/workflow-executions/get) use, so you can always fetch the full record if a delivery is missed.

### Resending a webhook

Call the [Resend Webhook API](/docs/api-reference/workflow-webhooks/resend) with the execution's id:

```bash theme={"system"}
curl -X POST https://api.bolna.ai/workflow-executions/{execution_id}/webhook:resend \
  -H "Authorization: Bearer YOUR_API_KEY"
```

It sends the webhook again to the workflow's **current** URL and headers, with the payload the execution originally produced — the same `occurred_at` included. That makes it the way to recover a delivery your server missed, and to redeliver after correcting a wrong URL. The response is `202` once the webhook is queued; the execution must have finished, and its workflow must have a webhook configured.
