# Add a step

Adds one step to a workflow: at a position on the main line, or inside a router branch.

- Endpoint: `POST https://api.returning.ai/v1/workflow-builder/data-workflows/{workflowId}/nodes`
- Section: Workflow Builder / Steps
- Authentication: `Authorization: Bearer <API_KEY>` (Community API key)
- Permission: `workflowBuilder`
- Retries: Adding a step is not idempotent: two accepted calls create two steps.
- Guide: generated from the published specification
- Last updated: 28 Oct 2026
- Web page: https://docs.returning.ai/api-reference/workflow-builder/add-a-step

## Authentication

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

`Authorization: Bearer <API_KEY>` with `workflowBuilder`, on a workflow of the key's community. `If-Match` is optional. Send the `revision` you read to avoid overwriting a concurrent change (a stale value is refused with `409 VERSION_CONFLICT`); omit it and your write wins as the last one. A deployed workflow is edited with the same call: the change applies immediately.

| Failure | Meaning | Recovery |
| --- | --- | --- |
| `400 VALIDATION_FAILED` | The options do not match the step type, or `position` names a step that is not on the main line | Read the type's schema from `GET /catalog`, or read the workflow for current step ids. |
| `404 BRANCH_NOT_FOUND` | `position.branchIndex` is out of range for that router | Read the router and use an existing branch index. |

## Behaviour

The step's options are validated against its type before anything is stored, and the position is resolved by the server.

A branch step is registered with its branch in the same call. The response carries the stored step and the workflow's new revision.

## 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>`. |
| `If-Match` | `string` | No | Optional. Send the `revision` the change is based on, bare or quoted, to avoid overwriting a concurrent change: a stale value is refused with `409 VERSION_CONFLICT`. Omit it for last-write-wins. |
| `Content-Type` | `string` | Yes | Request body format. (`application/json`) |

### Body

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `type` | `string` | Yes | Step type. (`http`, `platform-action`, `math`, `send-email`, `send-emails`, `code`, `router`, `users-bulk-update`) |
| `name` | `string` | Yes | Step name shown on the canvas. (Max 200 chars) |
| `position` | `object` | No | Where the step goes. Omit to append to the main line. |
| `position.after` | `string` | No | Insert after this step id, or after `trigger` for the first position. |
| `position.routerNodeId` | `string` | No | Router step id, when the step belongs inside a branch. |
| `position.branchIndex` | `integer` | No | Branch index inside that router. (Min 0) |
| `options` | `object` | No | Step options, shaped by the step type's schema from `GET /catalog`. |
| `continueOnFailure` | `boolean` | No | Keep going when this step fails. Defaults to `false`. |
| `retryOnFailure` | `boolean` | No | Retry this step when it fails. Defaults to `false`. |
| `ref` | `string` | No | Only for a node list posted in one call: a local name other steps can reference. Defaults to `node-<index>`. (Max 120 chars) |

### Watch for

- `position.after: "trigger"` inserts before the current first step. Omitting `position` appends to the end of the main line.
- A branch step is created on the main line first and then registered with its branch, so a step that is in a branch still appears in the workflow's id list.
- `options` must be the complete payload for the type. A partial options object is not merged with anything: there is nothing stored yet.
- A `platform-action` field key must be a real field of the community. A read action accepts `total_xp`, `total_coins`, `xp` and `coin`; a write action accepts `xp` and `coin`; a custom field uses its own key. `GET /catalog` lists them. A key outside the list is stored as sent, and `POST /{workflowId}/validate` reports `UNKNOWN_USER_FIELD` as a warning, so read the list instead of guessing: a step that reads nothing fails at run time, not at write time.
- A `label` you send is stored exactly as sent. Only a missing or empty label is filled from the catalog, for example `Total XP` for a read of `total_xp` and `XP` for a write to `xp`, so send the catalog's label unless you need a specific one.
- A read step's result is keyed by that label, so a later step references `{{<readStepId>.Total XP}}`, not the field key. Validation reports `REFERENCE_UNKNOWN_FIELD` when a reference names a label the step does not produce.
- An HTTP step's `auth` must be present. Use `{"type":"bearer-token","token":[]}` for "no auth"; an empty value is not treated as a secret.
- A credential-named header or parameter is redacted on read even when you authored it as a template. See `redactedPaths` in the response.
- A Code step is a normal step type here: `type: "code"` with `options.inputs` and `options.sourceCode.code`. It can be created, changed, duplicated, imported and tested like any other step; `sideEffects: true` because its source can write.
- A leaderboard step reads that leaderboard's own community, so it may only name a leaderboard of the key's community.
- `continueOnFailure` and `retryOnFailure` default to `false`. Set them explicitly when the workflow must continue past a failure.
- Send `logic` explicitly on every write step's field (`add`, `subtract`, `overwrite`). The stored default is `overwrite`, which can zero a balance, and nothing refuses the write: `POST /{workflowId}/validate` reports `POST_LOGIC_REQUIRED` as a warning.
- A step id should appear once across a router's branch lists: a second listing makes a run execute the step twice. Nothing refuses the write; `POST /{workflowId}/validate` reports `NODE_LISTED_TWICE` as a warning.
- `retryOnFailure` is for a step that can fail and be repeated safely: one HTTP call, one email, one bulk update, or a write of one field. `POST /{workflowId}/validate` warns with `RETRY_REPEATS_SIDE_EFFECTS` on a router whose branches contain a step that pays or calls outside, and on a write step that sets more than one field, because a retry would repeat several side effects at once. Nothing refuses the write.

### Example request

```bash
curl --request POST \
  --url 'https://api.returning.ai/v1/workflow-builder/data-workflows/<workflowId>/nodes' \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "type": "platform-action",
    "name": "Read member XP",
    "position": {
      "after": "trigger"
    },
    "options": {
      "action": "get-user-fields",
      "selectUser": {
        "type": "email",
        "value": ["{{65f1a2b3c4d5e6f708192a41.email}}"]
      },
      "fields": [
        {
          "label": "Total XP",
          "field": "total_xp"
        }
      ]
    }
  }'
```

### More examples

Append an HTTP step after an existing step:

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/nodes" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --header "If-Match: 162af81f5de4ff2b557a4087de9a9a73a0ed4e621f737787e35a93614cb50d1c" \
  --data '{"type":"http","name":"Notify receiver","position":{"after":"65f1a2b3c4d5e6f708192a3c"},"options":{"method":"POST","url":["https://receiver.example.com/trade-bonus"],"params":{},"headers":{"X-Api-Key":["<RECEIVER_KEY>"]},"bodyType":"json","body":{"member":["{{65f1a2b3c4d5e6f708192a41.email}}"]},"auth":{"type":"bearer-token","token":[]}}}'
```

A router with one condition branch and one fallback. Conditions use `AND` groups: `OR` and `WHERE` groups are stored and deployed, and the runtime then treats the group as always true, so validation warns `ROUTER_OPERATOR_UNSAFE`:

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/nodes" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --header "If-Match: 68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501" \
  --data '{"type":"router","name":"XP over 100?","position":{"after":"65f1a2b3c4d5e6f708192a3c"},"options":{"executePaths":"first_matching","branches":[{"branchName":"XP > 100","branchType":"CONDITION","conditions":[{"id":"group-1","operator":"AND","nestedGroups":[{"id":"nested-1","operator":"AND","conditions":[{"id":"condition-1","key":["{{65f1a2b3c4d5e6f708192a3c.Total XP}}"],"type":"number","operator":"greater_than","value":["100"]}]}]}]},{"branchName":"Otherwise","branchType":"FALLBACK","conditions":[]}]}}'
```

A step inside the router's first branch. The branch index is the position in `branches`, and the step is also added to the workflow's step order automatically:

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows/65f1a2b3c4d5e6f708192a3b/nodes" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --header "If-Match: 9ab88dc5d9fdaada545d24e2d76d58cd758c7b99eb905df867d90cdda294e12f" \
  --data '{"type":"platform-action","name":"Add 10 XP","position":{"routerNodeId":"65f1a2b3c4d5e6f708192a3e","branchIndex":0},"options":{"action":"post-user-fields","selectUser":{"type":"email","value":["{{65f1a2b3c4d5e6f708192a41.email}}"]},"fields":[{"label":"XP","field":"xp","logic":"add","value":["10"]}]}}'
```

```json
{
  "data": {
    "node": {
      "_id": "65f1a2b3c4d5e6f708192a3c",
      "name": "Read member XP",
      "type": "platform-action",
      "order": 1,
      "continueOnFailure": false,
      "retryOnFailure": false,
      "options": {
        "action": "get-user-fields",
        "selectUser": { "type": "email", "value": ["{{65f1a2b3c4d5e6f708192a41.email}}"] },
        "fields": [{ "label": "Total XP", "field": "total_xp" }]
      }
    },
    "revision": "68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501",
    "redactedPaths": []
  }
}
```

## Response

HTTP `201` with `{ node, revision, redactedPaths }`, and the new revision in the `ETag` header. `node._id` is the id every later step or reference uses.

Read back with `GET /{workflowId}`: the step must appear at the intended place, with the branch membership you asked for, and the options must match what you sent after normalisation. For an HTTP step with a JSON or raw body, the stored body is a single template element containing the JSON text, which is the shape the canvas shows.

### Response fields

| Field | Type | Presence | Description |
| --- | --- | --- | --- |
| `data` | `object` | - | One step with the workflow revision it left behind. |
| `data.node` | `object` | - | A step as returned by the API. |
| `data.node._id` | `string` | - | Step identifier. |
| `data.node.name` | `string` | - | Step name. |
| `data.node.type` | `string` | - | Step type. |
| `data.node.order` | `integer` | - | Position on the main line, from 1. |
| `data.node.continueOnFailure` | `boolean` | - | Whether a failure stops the run. |
| `data.node.retryOnFailure` | `boolean` | - | Whether the step is retried on failure. |
| `data.node.options` | `object` | - | Stored step options, with every credential replaced by `["__REDACTED__"]`. (Nullable) |
| `data.node.status` | `string` | - | Last run state of the step, when one exists. |
| `data.node.nestedNodes` | `object[]` | - | Present on a router: each branch carries its own `workflowNodeIds` and `nestedNodes`. |
| `data.revision` | `string` | - | Revision after the write. |
| `data.redactedPaths` | `string[]` | - | Credential locations replaced in this response. |

### Example response (201)

```json
{
  "data": {
    "node": {
      "_id": "65f1a2b3c4d5e6f708192a3c",
      "name": "Read member XP",
      "type": "platform-action",
      "order": 1,
      "continueOnFailure": false,
      "retryOnFailure": false,
      "options": {
        "action": "get-user-fields",
        "selectUser": {
          "type": "email",
          "value": ["{{65f1a2b3c4d5e6f708192a41.email}}"]
        },
        "fields": [
          {
            "label": "Total XP",
            "field": "total_xp"
          }
        ]
      }
    },
    "revision": "68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501",
    "redactedPaths": []
  }
}
```

## Errors

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | Options do not match the type, a required field is missing, or `position.after` is not on the main line. Fix the named field. |
| 400 | `NODE_NOT_IN_WORKFLOW` | A router's branch list names a step that is not part of this workflow. Reference only steps this workflow owns. |
| 400 | `LEADERBOARD_NOT_IN_COMMUNITY` | A leaderboard step names another community's leaderboard. Use a leaderboard of the key's community. |
| 400 | `REDACTED_VALUE_WITHOUT_STORED_SECRET` | A marker arrived on a new step, where nothing can be stored yet. Send the real credential. |
| 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. |

### Fix the data

| Status | Code | What to do |
| --- | --- | --- |
| 404 | `WORKFLOW_NOT_FOUND` | Unknown workflow id, or another community's workflow. Use an id from this community. |
| 404 | `BRANCH_NOT_FOUND` | `position.branchIndex` does not exist on that router. Read the router and use a valid index. |
| 409 | `VERSION_CONFLICT` | You sent an `If-Match` that is not the current revision. `meta.currentRevision` carries the live value. Re-read and resend against the new revision. |
| 409 | `WORKFLOW_LOCKED` | Another write to this workflow held the lock for too long, and this write was not applied. Re-read and retry. |

### 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. Re-read the workflow before retrying. |
| 503 | `LOCK_UNAVAILABLE` | The write lock store did not answer, so nothing was written. Retry shortly. |

**Retries:** Adding a step is not idempotent: two accepted calls create two steps. A retry after a timeout can leave a duplicate, so read the workflow and check for the step before resending. A retry with the revision from before the first attempt is refused with `409 VERSION_CONFLICT`, which is the safe outcome.

## Next step

- [Get a step](https://docs.returning.ai/api-reference/workflow-builder/get-a-step.md): `GET /v1/workflow-builder/data-workflows/{workflowId}/nodes/{nodeId}`.
