# Validate a stored workflow

Runs the same static check that deploy runs, without changing anything: each step's options against its type's schema, every reference resolving to an earlier step or the trigger, router branch shape and operators, retries that repeat side effects, credentials an import left empty, user-field keys, and leaderboard ownership.

- Endpoint: `POST https://api.returning.ai/v1/workflow-builder/data-workflows/{workflowId}/validate`
- Section: Workflow Builder / Tests and deploy
- Authentication: `Authorization: Bearer <API_KEY>` (Community API key)
- Permission: `workflowBuilder`
- Retries: Safe to retry: it never writes and never executes a step.
- Guide: generated from the published specification
- Last updated: 28 Oct 2026
- Web page: https://docs.returning.ai/api-reference/workflow-builder/validate-a-stored-workflow

## Authentication

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

No `If-Match` is needed because the call writes nothing: it always validates the stored workflow, and the body is ignored.

| Failure | Meaning | Recovery |
| --- | --- | --- |
| `403 API_KEY_PERMISSION_DENIED` | The key has no `workflowBuilder` permission | Grant the permission. |
| `404 WORKFLOW_NOT_FOUND` | Unknown id, or another community's workflow | Use an id from this community. |

## Behaviour

It is **advisory**. Every finding comes back in `warnings`, `errors` is always empty, and `valid` is always `true`: it does not tell you whether anything was found, so read `warnings`. No finding here refuses a deploy: deploy runs the same check, returns the same list as `warnings`, and proceeds either way. Deploy refuses only on its two tenant checks, a router branch that lists a step from another workflow (`400 NODE_NOT_IN_WORKFLOW`) and a leaderboard from another community (`400 LEADERBOARD_NOT_IN_COMMUNITY`); validate reports both as warnings. Read the report, decide, and fix what matters.

Nothing is executed and nothing is written, so a key that only inspects workflows can check one before anyone deploys it.

## 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`) |

### Watch for

- A report with warnings is deployable, and `valid` stays `true` either way: check whether `warnings` is empty, not `valid`. The platform accepts every one of these and then behaves as described.
- `MISSING_SECRET` appears only for credentials that were blanked by an import. A step whose authentication is deliberately empty is not reported. Because deploy does not refuse on it, an imported workflow can go live with a credential still missing: fill every path in `secretsToFill` first.
- Validation checks run order, not creation order: a reference may only read the trigger or a step that has already run on the same path. A step after a router cannot read a step inside one branch without a warning.
- A router branch that uses `OR` or `WHERE` is stored without complaint, deploys, and then evaluates as always true. Build conditions with `AND` groups and treat `ROUTER_OPERATOR_UNSAFE` as an editing task, not a blocker.
- Validation is not a test. It does not resolve member data, call an outside system, or prove that a leaderboard has members.
- `validate` writes nothing, so it is the cheapest way to check a workflow before you deploy or hand it over. It reaches outside the workflow only for two scoped reads: leaderboard ids and the community's user-field catalog.

### Example request

```bash
curl --request POST \
  --url 'https://api.returning.ai/v1/workflow-builder/data-workflows/<workflowId>/validate' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{}'
```

### More examples

A clean workflow:

```json
{
  "data": {
    "valid": true,
    "errors": [],
    "warnings": [],
    "revision": "162af81f5de4ff2b557a4087de9a9a73a0ed4e621f737787e35a93614cb50d1c"
  }
}
```

A workflow whose router uses an `OR` condition group, plus a reference to a branch step that may not run. Both are warnings, `valid` is still `true`, and the workflow can be deployed exactly as it is:

```json
{
  "data": {
    "valid": true,
    "errors": [],
    "warnings": [
      {
        "nodeId": "65f1a2b3c4d5e6f708192a3e",
        "path": "options.branches[0].conditions[0].operator",
        "code": "ROUTER_OPERATOR_UNSAFE",
        "message": "The runtime treats OR and WHERE condition groups as always true."
      },
      {
        "nodeId": "65f1a2b3c4d5e6f708192a3d",
        "path": "options.fields[0].value",
        "code": "REFERENCE_MAY_BE_UNSET",
        "message": "This reads a branch step that may not run; the value can be unset."
      }
    ],
    "revision": "68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501"
  }
}
```

## Response

HTTP `200` whenever the workflow was read and checked, whatever the report says: a workflow with warnings is a successful validation with `valid: true`, an empty `errors` and a non-empty `warnings`. There is no state change, so the readback is the report itself plus `revision`, which is stable until the next authored change.

Warning codes a report can carry. **None of them stops a deploy.** `deploy` returns the same list in its own `warnings`, so a workflow can go live with every code below present:

The router and reference rows come from the same rules the run path uses, including its operator tables. That is what makes the report worth reading even though it refuses nothing: it names exactly what a run will ignore or misread.

### Response fields

| Field | Type | Presence | Description |
| --- | --- | --- | --- |
| `data` | `object` | - | Advisory static check result. Every finding is a warning, `valid` is always `true` (read `warnings` to see what was found), and no finding refuses a deploy. |
| `data.valid` | `boolean` | - | Always `true`: validation is advisory. Whether anything was found is in `warnings`. Nothing here blocks a deploy. |
| `data.errors` | `object[]` | - | Always empty: the validator reports every finding in `warnings`. |
| `data.errors.nodeId` | `string` | - | Step the problem belongs to, when it belongs to one. |
| `data.errors.path` | `string` | - | Field path, for example `options.branches[0].conditions[0].operator`. |
| `data.errors.code` | `string` | - | Stable machine code for the problem. |
| `data.errors.message` | `string` | - | Human explanation. |
| `data.warnings` | `object[]` | - | Every finding, advisory: a schema mismatch, a reference that may not resolve, a router shape, a retry that repeats side effects, an empty imported credential. Deploy proceeds and reports the same list. |
| `data.warnings.nodeId` | `string` | - | Step the warning belongs to. |
| `data.warnings.path` | `string` | - | Field path. |
| `data.warnings.code` | `string` | - | Stable machine code for the warning. |
| `data.warnings.message` | `string` | - | Human explanation. |
| `data.revision` | `string` | - | Revision the report was computed from. |

### Example response (200)

```json
{
  "data": {
    "valid": true,
    "errors": [],
    "warnings": [],
    "revision": "162af81f5de4ff2b557a4087de9a9a73a0ed4e621f737787e35a93614cb50d1c"
  }
}
```

## Errors

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | The id is not 24 hexadecimal characters, or the body is not a JSON object. Send `{}` and a valid id. |
| 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` | No `workflowBuilder`. Grant the permission. |
| 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 with `{}`. |

### 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 with bounded backoff. |

**Retries:** Safe to retry: it never writes and never executes a step. The report can differ between calls only because the workflow changed, so compare `revision` between reports rather than assuming they describe the same state.

## Next step

- [Test one step](https://docs.returning.ai/api-reference/workflow-builder/test-one-step.md): `POST /v1/workflow-builder/data-workflows/{workflowId}/nodes/{nodeId}/test`.
