# Create a workflow

Creates a draft workflow, its single trigger, and optionally all of its steps, in one call.

- Endpoint: `POST https://api.returning.ai/v1/workflow-builder/data-workflows`
- Section: Workflow Builder / Workflows
- Authentication: `Authorization: Bearer <API_KEY>` (Community API key)
- Permission: `workflowBuilder`
- Retries: Without `Idempotency-Key`, a retry creates a second workflow.
- Guide: generated from the published specification
- Last updated: 28 Oct 2026
- Web page: https://docs.returning.ai/api-reference/workflow-builder/create-a-workflow

## Authentication

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

The new workflow belongs to the key's community; there is no community field in the body and no way to create into another community.

The body must be declared `application/json`. Any other content type is refused with `415` before validation.

| Failure | Meaning | Recovery |
| --- | --- | --- |
| `401 AUTHENTICATION_REQUIRED` | No key, or an unknown key | Send a valid key. |
| `403 API_KEY_PERMISSION_DENIED` | The key lacks `workflowBuilder` | Grant the permission. |

## Behaviour

The workflow is always stored as `draft`: nothing runs until `POST /{workflowId}/deploy`.

The response carries the new workflow with its first `revision`, and a webhook trigger's generated key is shown once here. A create never changes another workflow and never activates anything.

## Request

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | Yes | Community API key: `Bearer <API_KEY>`. |
| `Idempotency-Key` | `string` | No | Optional client-chosen key. The same key with the same body returns the original result instead of creating a second object. |
| `Content-Type` | `string` | Yes | Request body format. (`application/json`) |

### Body

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Workflow name. (1-200 chars) |
| `trigger` | `object` | No | Optional trigger, created with the workflow in the same call. (Nullable) |
| `trigger.type` | `string` | Yes | Trigger type. (`time`, `webhook`, `visit-community`, `join-community`, `custom-field-update`, `conditions-trigger`, `event-ingest`) |
| `trigger.name` | `string` | No | Display name. Defaults to `Trigger`. (Max 200 chars) |
| `trigger.options` | `object` | No | Trigger options, validated against the trigger type's schema from `GET /catalog`. (Nullable) |
| `nodes` | `object[]` | No | Optional steps, created in order. At most 200. (Max 200 items) |
| `nodes.type` | `string` | Yes | Step type. (`http`, `platform-action`, `math`, `send-email`, `send-emails`, `code`, `router`, `users-bulk-update`) |
| `nodes.name` | `string` | Yes | Step name shown on the canvas. (Max 200 chars) |
| `nodes.options` | `object` | No | Step options, shaped by the step type's schema from `GET /catalog`. |
| `nodes.continueOnFailure` | `boolean` | No | Keep going when this step fails. Defaults to `false`. |
| `nodes.retryOnFailure` | `boolean` | No | Retry this step when it fails. Defaults to `false`. |
| `nodes.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) |
| `nodes.position` | `object` | No | Where the step goes. Omit to append to the main line. |
| `nodes.position.after` | `string` | No | Insert after this step id, or after `trigger` for the first position. |
| `nodes.position.routerNodeId` | `string` | No | Router step id, when the step belongs inside a branch. |
| `nodes.position.branchIndex` | `integer` | No | Branch index inside that router. (Min 0) |
| `definition` | `object` | No | Optional export document to import instead of `nodes`. |

### Watch for

- `nodes` and `definition` are mutually exclusive. Sending both is refused rather than merged.
- A router branch member that is not one of this workflow's steps is not refused when the workflow is created. `POST /{workflowId}/validate` reports it as `NODE_NOT_IN_WORKFLOW`, and deploy refuses it with `400 NODE_NOT_IN_WORKFLOW`, so reference only steps this document owns.
- `nodes` are created in array order. `position` is accepted there for shape compatibility but the array order is what a create stores.
- A `ref` in `nodes` is only a local name for a reference inside the same call. Real step ids come back from the following `GET`.
- The trigger is optional. A workflow with no trigger can be created and edited, and it can be deployed: deploy never refuses on validation, and `POST /{workflowId}/validate` reports `TRIGGER_REQUIRED` as a warning. It simply never runs until a trigger is set.
- A webhook key is returned in clear exactly once, in this response. Store it when you see it: every later read returns `["__REDACTED__"]`, and a `PUT /{workflowId}/trigger` keeps the stored key rather than accepting a new one.
- The response body is never logged by the platform, but it can hold a key. Keep the raw response out of your own logs.
- `createdAt` and `updatedAt` are equal on a create. They are not the revision, and neither is used for concurrency.

### Example request

```bash
curl --request POST \
  --url https://api.returning.ai/v1/workflow-builder/data-workflows \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "VIP bonus",
    "trigger": {
      "type": "webhook",
      "name": "Trade bonus trigger",
      "options": {
        "tokenExchangeEnabled": false,
        "apiKey": {
          "enabled": true
        }
      }
    }
  }'
```

### More examples

A name and a webhook trigger. With `apiKey.enabled: true` and no value, the platform mints the key and returns it in this response only.

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{"name":"VIP bonus","trigger":{"type":"webhook","name":"Trade bonus trigger","options":{"tokenExchangeEnabled":false,"apiKey":{"enabled":true}}}}'
```

A trigger and one step in a single call. Steps are created in the order given, not in the order a `position` asks for:

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{"name":"VIP bonus","trigger":{"type":"event-ingest","name":"Trades","options":{"batchIdPath":"metadata.batch_id","recordIdPaths":["trade_id"]}},"nodes":[{"type":"platform-action","name":"Read member XP","ref":"read-member","options":{"action":"get-user-fields","selectUser":{"type":"email","value":["{{65f1a2b3c4d5e6f708192a41.email}}"]},"fields":[{"label":"Total XP","field":"total_xp"}]}}]}'
```

A definition exported from another workflow, posted as `{definition}`. The document must carry `kind: "returningai.workflow"`, `formatVersion: 1` and `variant: "data_workflows"`, or the import is refused:

```bash
curl --request POST \
  --url "https://api.returning.ai/v1/workflow-builder/data-workflows" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{"name":"VIP bonus (imported)","definition":{"kind":"returningai.workflow","formatVersion":1,"variant":"data_workflows","workflow":{"name":"VIP bonus"},"trigger":null,"nodes":[]}}'
```

The response of the first example:

```json
{
  "data": {
    "_id": "65f1a2b3c4d5e6f708192a3b",
    "communityId": "65f1a2b3c4d5e6f708192a50",
    "name": "VIP bonus",
    "status": "draft",
    "outgoingWebhookUrl": null,
    "createdAt": "2026-10-24T09:15:00.000Z",
    "updatedAt": "2026-10-24T09:15:00.000Z",
    "trigger": {
      "_id": "65f1a2b3c4d5e6f708192a41",
      "name": "Trade bonus trigger",
      "type": "webhook",
      "status": "idle",
      "options": { "tokenExchangeEnabled": false, "apiKey": { "enabled": true, "value": "<WEBHOOK_KEY>" } }
    },
    "nodes": [],
    "revision": "162af81f5de4ff2b557a4087de9a9a73a0ed4e621f737787e35a93614cb50d1c",
    "redactedPaths": [],
    "secretsToFill": []
  }
}
```

## Response

HTTP `201` with the workflow in `data`, its `revision` both in the body and in the `ETag` header. Keep `_id`, `revision`, `trigger._id` and, for a webhook trigger, the generated key.

Read back with `GET /{workflowId}`: the name, status, trigger type and step count must match what you sent. That read also proves the webhook key is now redacted, which is the expected state after the one reveal.

`secretsToFill` lists credential paths that an imported definition left empty. It is empty for a plain create.

### Response fields

| Field | Type | Presence | Description |
| --- | --- | --- | --- |
| `data` | `object` | - | A workflow with its trigger, its steps in canvas order and its revision. |
| `data._id` | `string` | - | Workflow identifier. |
| `data.communityId` | `string` | - | Community the workflow belongs to. |
| `data.name` | `string` | - | Workflow name. |
| `data.status` | `string` | - | `draft`, `deployed` or `undeployed`. |
| `data.outgoingWebhookUrl` | `string` | - | Failure webhook URL, when one is set. (Nullable) |
| `data.createdAt` | `string` | - | Creation time. |
| `data.updatedAt` | `string` | - | Last authored change. |
| `data.trigger` | `object` | - | The trigger, or `null` when the workflow has none. (Nullable) |
| `data.trigger._id` | `string` | - | Trigger identifier. Use it as the source of a reference. |
| `data.trigger.name` | `string` | - | Display name. |
| `data.trigger.type` | `string` | - | Trigger type. |
| `data.trigger.status` | `string` | - | Trigger lifecycle state, for example `idle`. |
| `data.trigger.options` | `object` | - | Trigger options. A webhook key is shown only in the response that created it. (Nullable) |
| `data.nodes` | `object[]` | - | Main-line steps in canvas order, routers carrying their branches. |
| `data.nodes._id` | `string` | - | Step identifier. |
| `data.nodes.name` | `string` | - | Step name. |
| `data.nodes.type` | `string` | - | Step type. |
| `data.nodes.order` | `integer` | - | Position on the main line, from 1. |
| `data.nodes.continueOnFailure` | `boolean` | - | Whether a failure stops the run. |
| `data.nodes.retryOnFailure` | `boolean` | - | Whether the step is retried on failure. |
| `data.nodes.options` | `object` | - | Stored step options, with every credential replaced by `["__REDACTED__"]`. (Nullable) |
| `data.nodes.status` | `string` | - | Last run state of the step, when one exists. |
| `data.nodes.nestedNodes` | `object[]` | - | Present on a router: each branch carries its own `workflowNodeIds` and `nestedNodes`. |
| `data.revision` | `string` | - | 64 hexadecimal characters. Send it back as `If-Match`; it also arrives as the `ETag` header. |
| `data.redactedPaths` | `string[]` | - | Every location where a credential was replaced by `["__REDACTED__"]`. |

### Example response (200)

```json
{
  "data": {
    "_id": "65f1a2b3c4d5e6f708192a3b",
    "communityId": "65f1a2b3c4d5e6f708192a50",
    "name": "VIP bonus",
    "status": "draft",
    "outgoingWebhookUrl": null,
    "createdAt": "2026-10-24T09:15:00.000Z",
    "updatedAt": "2026-10-24T09:20:11.000Z",
    "trigger": {
      "_id": "65f1a2b3c4d5e6f708192a41",
      "name": "Trade bonus trigger",
      "type": "webhook",
      "status": "idle",
      "options": {
        "tokenExchangeEnabled": false,
        "apiKey": {
          "enabled": false
        },
        "aesKey": {
          "enabled": false
        }
      }
    },
    "nodes": [
      {
        "_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": "162af81f5de4ff2b557a4087de9a9a73a0ed4e621f737787e35a93614cb50d1c",
    "redactedPaths": []
  }
}
```

## Errors

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | Missing or empty `name`, both `nodes` and `definition`, more than 200 nodes, an unknown trigger type, or invalid step options. `errors[].path` names the field. Fix the named field. |
| 400 | `LEADERBOARD_NOT_IN_COMMUNITY` | A leaderboard step in `nodes` or `definition` names a leaderboard of another community. Use a leaderboard of the key's community. |
| 400 | `REDACTED_VALUE_WITHOUT_STORED_SECRET` | The body contains the redaction marker where nothing is stored, for example a copied export posted as a create. Send the real credential, or import the definition so the marker is blanked and listed. |
| 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 |
| --- | --- | --- |
| 409 | `IDEMPOTENCY_KEY_REUSED` | The same `Idempotency-Key` arrived with a different body. Use a new key for a genuinely new create. |
| 409 | `WORKFLOW_LOCKED` | An identical request is still being processed. Retry shortly; the first call will produce the workflow. |

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

**Retries:** Without `Idempotency-Key`, a retry creates a second workflow. With it, the same key and the same body return the first workflow as `200` instead of creating another, for 24 hours. That replay is the read of the existing workflow, so a webhook key that the first `201` returned in clear comes back as `["__REDACTED__"]`. The stored idempotency record holds a digest of the body, never the body itself, so a credential in a create body is not readable from it. A different body under the same key is refused with `409 IDEMPOTENCY_KEY_REUSED`; a genuinely new workflow needs a new key.

## Next step

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