# Test a whole workflow

Walks the workflow from its trigger and returns one trace entry per step it reached, in execution order.

- Endpoint: `POST https://api.returning.ai/v1/workflow-builder/data-workflows/{workflowId}/test`
- Section: Workflow Builder / Tests and deploy
- Authentication: `Authorization: Bearer <API_KEY>` (Community API key)
- Permission: `workflowBuilder`
- Retries: A dry run is safe to retry: it writes no history, no step status, no member data, and makes no outbound call.
- Guide: generated from the published specification
- Last updated: 28 Oct 2026
- Web page: https://docs.returning.ai/api-reference/workflow-builder/test-a-whole-workflow

## Authentication

- Header: `Authorization: Bearer <API_KEY>`
- Permission: `workflowBuilder`

`Authorization: Bearer <API_KEY>` with `workflowBuilder`, on a workflow of the key's community. One permission covers both modes; there is no separate live-test permission and no confirmation header, so `mode` is the only thing that decides whether the walk costs anything.

The workflow must have a trigger, because the walk starts from its output. A single-step test does not have that requirement.

| Failure | Meaning | Recovery |
| --- | --- | --- |
| `400 VALIDATION_FAILED` | The workflow has no trigger | `PUT /{workflowId}/trigger` first. |
| `404 WORKFLOW_NOT_FOUND` | Unknown id, or another community's workflow | Use an id from this community. |

## Behaviour

`mode` decides whether the real step services run:

- `mode: "live"` is the **default** and behaves like a triggered run: every step executes for real, in order, and may write, send, call outside or pay a reward.
- `mode: "dry-run"` is **opt-in** and side-effect free: read-only steps really run, and every step that would write, send or call is simulated with the inputs it would have used.

A trace shows which branch the run took, or would take. That is the part no single step test can answer.

## Request

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `workflowId` | `string` | Yes | Workflow identifier: 24 hexadecimal characters, as returned in `_id`. (`^[0-9a-fA-F]{24}$`) |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | Yes | Community API key: `Bearer <API_KEY>`. |
| `Content-Type` | `string` | Yes | Request body format. (`application/json`) |

### Body

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | `string` | No | `live` (default) runs the real step services and may write or pay rewards. `dry-run` is opt-in and side-effect free. (`dry-run`, `live`; default `live`) |
| `triggerPayload` | `string` | No | - |
| `input` | `object` | No | Earlier step results keyed by step id, for a single-step test. |

### Watch for

- The default mode is `live`. An empty body therefore runs the whole workflow for real: send `{"mode":"dry-run"}` while you are still editing, and keep the live call for the change you mean to make.
- `status: "success"` means no step failed. It does not mean a simulated step did anything, and it is not a deploy.
- A simulated step produces no output, so a later step that reads it is not executed: it is reported as `failed` with `UNRESOLVED_SIMULATED_REFERENCE`, and the workflow's `status` is `fail`. That is deliberate: an invented value would be worse than an obvious gap. A workflow test's `input` seeds earlier step results for a single-step test, not for this walk, so it does not lift the block. Test the later step on its own with `POST /{workflowId}/nodes/{nodeId}/test` and `input`, or run the workflow live.
- The trigger's stored sample result is used when no `triggerPayload` is sent. Seed every test with the event shape you expect, or you are testing the sample.
- A live test leaves no run in the workflow's history, so "test then read the run" is not a sequence. Deploy and fire the trigger for a real run.
- A workflow needs nothing validated to be tested: `POST /{workflowId}/validate` reports the advisory findings, and neither a test nor a deploy refuses on them.
- `scenario` does not exist: one call tests one payload. Send several calls for several events.

### Example request

```bash
curl --request POST \
  --url 'https://api.returning.ai/v1/workflow-builder/data-workflows/<workflowId>/test' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "mode": "dry-run",
    "triggerPayload": {
      "email": "trader@example.com",
      "trade_id": "T-1024",
      "volume": "2.5"
    }
  }'
```

### More examples

A dry run seeded with a synthetic event:

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/test" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{"mode":"dry-run","triggerPayload":{"email":"trader@example.com","trade_id":"T-1024","volume":"2.5"}}'
```

A live run, which is what an omitted `mode` gives you: it behaves like a real run of the workflow:

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/test" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{"mode":"live","triggerPayload":{"email":"trader@example.com"}}'
```

A dry run that takes the condition branch: the read step executed, the write step was simulated, and the other branch was skipped.

```json
{
  "data": {
    "mode": "dry-run",
    "sideEffects": "none",
    "workflowId": "65f1a2b3c4d5e6f708192a3b",
    "status": "success",
    "trace": [
      { "nodeId": "65f1a2b3c4d5e6f708192a3c", "name": "Read member XP", "type": "platform-action", "status": "executed", "output": { "XP": 150 }, "warnings": [] },
      { "nodeId": "65f1a2b3c4d5e6f708192a3e", "name": "XP over 100?", "type": "router", "status": "executed", "warnings": [] },
      { "nodeId": "65f1a2b3c4d5e6f708192a3d", "name": "Add 10 XP", "type": "platform-action", "branch": "XP > 100", "branchIndex": 1, "status": "simulated", "wouldDo": { "action": "post-user-fields" }, "warnings": [] },
      { "nodeId": "65f1a2b3c4d5e6f708192a3f", "name": "Notify receiver", "type": "http", "branch": "Otherwise", "branchIndex": 2, "status": "skipped", "warnings": [] }
    ],
    "redactedPaths": []
  }
}
```

## Response

HTTP `200` with `mode`, `sideEffects`, `status` (`success` when no step failed, `fail` otherwise), `trace` and `redactedPaths`.

Each trace entry carries the step id, name and type, its `status`, and for a step inside a router the `branch` name and its `branchIndex`. **`branchIndex` in a trace is 1-based**, matching the router result the canvas shows: the first branch is `1`. The write side counts from zero instead, so `position.branchIndex` and the `branches/{branchIndex}` path name the first branch as `0`. A step on a branch the run did not take is `skipped`, which is how a trace proves the branch decision.

A live test also returns `historyId`: the transient record the live run produced. The platform removes that record after the call, so it is an identifier for the run you just caused, not a history entry to read back.

For a dry run, prove nothing changed with a read on the data the workflow would touch: the member's field value, or the last run in `GET /{workflowId}/runs`, must be the same before and after.

### Response fields

| Field | Type | Presence | Description |
| --- | --- | --- | --- |
| `data` | `object` | - | A whole-workflow test result. |
| `data.mode` | `string` | - | `dry-run` or `live`. |
| `data.sideEffects` | `string` | - | `none` for a dry run, `real` for a live run. |
| `data.workflowId` | `string` | - | Workflow under test. |
| `data.status` | `string` | - | `success` when no step failed, `fail` otherwise. |
| `data.trace` | `object[]` | - | One entry per step the walk reached. |
| `data.trace.nodeId` | `string` | - | Step identifier. |
| `data.trace.name` | `string` | - | Step name. |
| `data.trace.type` | `string` | - | Step type. |
| `data.trace.status` | `string` | - | `executed` for a read-only step that really ran, `simulated` for one that would have written, `skipped` or `failed`. |
| `data.trace.output` | `object` | - | Step result, for an executed step. (Nullable) |
| `data.trace.warnings` | `string[]` | - | Anything the caller should know about the entry. |
| `data.trace.branch` | `string` | - | Branch name, for a step inside a router branch. |
| `data.trace.branchIndex` | `integer` | - | Branch index in the trace. Results are 1-based, matching the router result the canvas shows; `position.branchIndex` and the branch path parameter count from 0. |
| `data.trace.wouldDo` | `object` | - | Resolved inputs, for a simulated step. |
| `data.trace.code` | `string` | - | Machine code for a failure. |
| `data.trace.message` | `string` | - | Human explanation for a failure. |
| `data.redactedPaths` | `string[]` | - | Credential locations replaced in this response. |
| `data.historyId` | `string` | - | Set on a live test: the transient record the run produced. |

### Example response (200)

```json
{
  "data": {
    "mode": "dry-run",
    "sideEffects": "none",
    "workflowId": "65f1a2b3c4d5e6f708192a3b",
    "status": "success",
    "trace": [
      {
        "nodeId": "65f1a2b3c4d5e6f708192a3c",
        "name": "Read member XP",
        "type": "platform-action",
        "status": "executed",
        "output": {
          "Total XP": 150
        },
        "warnings": []
      },
      {
        "nodeId": "65f1a2b3c4d5e6f708192a3e",
        "name": "XP over 100?",
        "type": "router",
        "status": "executed",
        "warnings": []
      },
      {
        "nodeId": "65f1a2b3c4d5e6f708192a3d",
        "name": "Add 10 XP",
        "type": "platform-action",
        "branch": "XP > 100",
        "branchIndex": 1,
        "status": "simulated",
        "wouldDo": {
          "action": "post-user-fields"
        },
        "warnings": []
      },
      {
        "nodeId": "65f1a2b3c4d5e6f708192a3f",
        "name": "Notify receiver",
        "type": "http",
        "branch": "Otherwise",
        "branchIndex": 2,
        "status": "skipped",
        "warnings": []
      }
    ],
    "redactedPaths": []
  }
}
```

## Errors

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | No trigger, an unsupported `mode`, or a body that is not a JSON object. Set a trigger first, and send a supported mode. |
| 401 | `AUTHENTICATION_REQUIRED` | No key, or an unknown key. Send a valid key. |
| 401 | `API_KEY_REQUIRED` | A valid admin session was used, because this API accepts community API keys only. Use a community API key. |
| 403 | `API_KEY_PERMISSION_DENIED` | The key has no `workflowBuilder` permission. Grant it. |
| 413 | `VALIDATION_FAILED` | The request body is too large to read. Nothing was written. Send a smaller body; a large import is better split into a create plus step writes. |
| 415 | `UNSUPPORTED_MEDIA_TYPE` | The body was not declared `application/json`. Send the header. |

### Fix the data

| Status | Code | What to do |
| --- | --- | --- |
| 404 | `WORKFLOW_NOT_FOUND` | Unknown id, or another community's workflow. Use an id from this community. |

### Retry with backoff

| Status | Code | What to do |
| --- | --- | --- |
| 429 | `RATE_LIMITED` | The community's API rate limit was reached. Wait for the window to reset, then retry. |
| 500 | `INTERNAL_ERROR` | Unexpected failure. Retry a dry run with bounded backoff. |

**Retries:** A dry run is safe to retry: it writes no history, no step status, no member data, and makes no outbound call. A live test is not safe to retry. It runs every step for real, in order, exactly like a triggered run, so a second accepted call can pay a second reward. After a timeout, read the member's history and the run list before deciding.

## Next step

- [Deploy a workflow](https://docs.returning.ai/api-reference/workflow-builder/deploy-a-workflow.md): `POST /v1/workflow-builder/data-workflows/{workflowId}/deploy`.
