# Test without side effects, then confirm a live run

A workflow that pays members needs two separate answers. First: it does what you think, without paying anyone. Second: it really runs when the trigger fires, and it pays exactly once.

The API separates those two steps with one switch: `mode`. A dry run writes nothing; `mode: "live"` is the default, so the deliberate act is choosing the dry run, not the live one. Send `mode: "dry-run"` for every iteration and keep `live` for the run you mean.

## The three modes, and what each one proves

| Mode | What it does | What it proves |
| --- | --- | --- |
| `mode: "dry-run"` | Read-only steps run for real; everything that writes, sends or calls is resolved and reported | The logic, the branch taken, the resolved member and amounts |
| `mode: "live"` test (the default) | Every step really runs, like a triggered run | The workflow executes end to end, but leaves no run in the history |
| Triggered run | The workflow runs because its trigger fired | The whole chain: trigger, workflow, run, side effects |

## 1. Prepare a synthetic member

Take every field key from the catalog rather than guessing one: [`GET /catalog`](https://docs.returning.ai/api-reference/workflow-builder/list-step-types-and-trigger-types.md) lists them in `platformActionFields.get` (read) and `platformActionFields.post` (write). See [Field keys come from the catalog](https://docs.returning.ai/workflow-builder.md#field-keys-come-from-the-catalog).

Use a member you created for this purpose, and read their current values through the platform's user endpoints. Record the baseline: field values, history rows, and any job counts you will compare.

## 2. Dry-run the workflow

`Test a whole 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": "dry-run",
    "triggerPayload": {
      "email": "trader@example.com",
      "event_id": "sample-1",
      "volume": "2.5"
    }
  }'
```

Readback, in this order:

1. `sideEffects: "none"`.
2. Every write step is `simulated`, with `wouldDo` naming the member, the field, the amount and the logic.
3. The branch the trace took is the branch you expected.
4. Any step that is `failed` names why.

Then prove nothing changed: read the member's value and the workflow's runs again. The counts must be identical to the baseline.

## 3. Prove the negative case

Send a second dry run with a payload that must not pay: an ineligible member, a zero volume, a missing identifier.

`Test a whole 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": "dry-run",
    "triggerPayload": {
      "email": "trader@example.com",
      "event_id": "sample-2",
      "volume": "0"
    }
  }'
```

The trace must take the other branch, or fail the step for the reason you expect. A workflow that pays everybody is not a workflow you have tested.

## 4. Decide about the live test

A live test executes for real. It is useful when you need to know the steps run with real data, and it is not a substitute for a real trigger: it leaves no run in the history, so it cannot prove that the trigger is reachable or that the run was recorded.

> A live test runs for real
>
> It needs `workflowBuilder` like every other call, and nothing else: there is no live-test permission and no confirmation header. The mode is the only thing that makes it live, so send it deliberately.

`Test a whole 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",
      "event_id": "sample-live-1"
    }
  }'
```

Readback: `sideEffects: "real"`, a `status`, the trace, and a `historyId` for the transient record the call created. Then read the member's value again: the delta must be exactly one reward.

Skip this mode when the workflow pays a live product and a dry run plus a triggered synthetic run already answers the question. The triggered run is the stronger evidence anyway.

## 5. Deploy and fire the real trigger

`Deploy a workflow`

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/deploy" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "If-Match: 68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501"
```

Fire the trigger the way a real sender does, with a fresh event id and a synthetic member. Then read, in order:

1. [`GET /{workflowId}/runs`](https://docs.returning.ai/api-reference/workflow-builder/list-the-runs-of-a-workflow.md): a new run, with a status.
2. [`GET /runs/{runId}`](https://docs.returning.ai/api-reference/workflow-builder/get-one-run.md): per-step results, including the branch taken.
3. The member's value: exactly one delta.
4. For a bulk step, the job's per-row detail.
5. A replay of the same event: no second delta.

Each link is a separate claim. A run `success` with an unchanged member value is a failure that looks like a success.

## What stays behind

A dry run writes no history, no step status, no member data and no outbound call. A live test leaves the member's new value: that part is real and must be recorded in your own evidence.

## Failure handling

| What you see | What it means | What to do |
| --- | --- | --- |
| `403 API_KEY_PERMISSION_DENIED` | The key has no `workflowBuilder` permission | Grant it |
| A live result where you meant a dry run | `mode` was omitted, and `live` is the default | Send `mode: "dry-run"`, and check `sideEffects` in the response: `none` means nothing was written |
| The trace shows a step `failed` with `UNRESOLVED_SIMULATED_REFERENCE` | That step reads a step the dry run simulated, and a simulated step produces no output; the workflow status is `fail` | Test that step directly with `input`, or dry-run a workflow whose earlier steps are read-only |
| Run `success`, member unchanged | The step was skipped or the amount was zero | Read the run detail and the branch |
| Two deltas for one event | The event reached the workflow twice | Fix deduplication before the next cycle |
| Deploy succeeded with warnings you did not read | Validation is advisory, so no validation finding refuses a deploy | Read `warnings` from the deploy response, and act on each one before the next cycle |

## Cleanup

Undeploy and delete the test workflow when it exists only for this check. Remove the synthetic member through the platform's user endpoints, or keep it deliberately and say so in your record. Delete any copy of a credential that the run produced in a log or artifact.

## What the API does not cover

- Cancelling a payment that a mistaken live run made. That is your reversal process.
- Reading the member's own values. Those are platform endpoints.
- A trigger that fires a test event from the platform's side. The sender fires it.
- A permission that keeps a key from spending money. One permission covers the whole family, so the guard is the `mode` you pass and the instructions you give the agent or script holding the key.
- Confirming that a member-facing screen shows the reward. That is a separate visual check.

## Endpoints on this page

- POST [Test a whole workflow](https://docs.returning.ai/api-reference/workflow-builder/test-a-whole-workflow.md) `/{workflowId}/test`
- POST [Deploy a workflow](https://docs.returning.ai/api-reference/workflow-builder/deploy-a-workflow.md) `/{workflowId}/deploy`
- GET [List step types and trigger types](https://docs.returning.ai/api-reference/workflow-builder/list-step-types-and-trigger-types.md) `/catalog`
- GET [List the runs of a workflow](https://docs.returning.ai/api-reference/workflow-builder/list-the-runs-of-a-workflow.md) `/{workflowId}/runs`
- GET [Get one run](https://docs.returning.ai/api-reference/workflow-builder/get-one-run.md) `/runs/{runId}`

Paths that start with `/v1/` are full paths; the rest are relative to `https://api.returning.ai/v1/workflow-builder/data-workflows`.
