# Duplicate a workflow

Copies a workflow, its trigger and all of its steps into a new workflow named `<name> (Copy)`, stored as a draft with new identifiers.

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

## Authentication

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

`Authorization: Bearer <API_KEY>` with `workflowBuilder`, on a workflow of the key's community. The call reads the source and writes a new draft, and the source is not modified.

`If-Match` is optional. Send the revision you read and the copy is made from that state, with `409 VERSION_CONFLICT` instead of a copy of a workflow that changed under you. Without it, the copy is taken from whatever is current.

| Failure | Meaning | Recovery |
| --- | --- | --- |
| `409 VERSION_CONFLICT` | The source changed since your read | Re-read and duplicate again. |

## Behaviour

Every reference is rewired to the copies: a `{nodeId, field}` reference object, a field value that names a step, a router branch's step list, and a `{{<stepId>.path}}` template, including one inside a JSON body template or a URL. A template is paired with the step it named, not with a position, so a step inserted after the trigger or a step inside a router branch still lands on the right copy. The copy references only its own trigger and steps, so it is deployable without editing a reference.

The copy is the safe way to change a live workflow: duplicate, edit the copy, test it, deploy the copy, then undeploy the original.

## 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. |

### Watch for

- The copy always gets the suffix `(Copy)`; renaming it is a separate `PATCH`.
- A webhook trigger's key is copied as stored, and no new key is revealed in this response, so the copy accepts the same key as the source. A `PUT` on the copy keeps that key, so the two workflows share one credential until you replace the copy's webhook trigger (another type and back to `webhook`) and reconfigure its sender.
- The copy keeps the source's failure webhook URL. Two live workflows then report failures to the same receiver; change or clear it on the copy.
- Credentials in step options are copied as stored. They are not re-supplied and not revealed.
- A workflow with no trigger can be duplicated; the copy also has no trigger. It deploys (deploy never refuses on validation) but never runs until a trigger is set.
- Deployment state does not travel: the copy is always a draft.

### Example request

```bash
curl --request POST \
  --url 'https://api.returning.ai/v1/workflow-builder/data-workflows/<workflowId>/duplicate' \
  --header 'Authorization: Bearer <API_KEY>'
```

### More examples

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

The response is the copy, already read back in full:

```json
{
  "data": {
    "_id": "65f1a2b3c4d5e6f708192a43",
    "communityId": "65f1a2b3c4d5e6f708192a50",
    "name": "VIP bonus (Copy)",
    "status": "draft",
    "outgoingWebhookUrl": null,
    "createdAt": "2026-10-24T12:01:03.000Z",
    "updatedAt": "2026-10-24T12:01:03.000Z",
    "trigger": { "_id": "65f1a2b3c4d5e6f708192a47", "name": "Trade bonus trigger", "type": "event-ingest", "status": "idle", "options": { "batchIdPath": "metadata.batch_id", "recordIdPaths": ["trade_id"] } },
    "nodes": [],
    "revision": "68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501",
    "redactedPaths": []
  }
}
```

## Response

HTTP `201` with the new workflow in `data`, its new `revision` in the body and the `ETag` header. The copy is the readback: it shows the new ids, the new name and `status: draft`.

Every identifier is new: the workflow, the trigger and each step. Any reference inside the copy points at the copied step, not at the source step, so the two workflows can be edited independently.

### 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 (201)

```json
{
  "data": {
    "_id": "65f1a2b3c4d5e6f708192a43",
    "communityId": "65f1a2b3c4d5e6f708192a50",
    "name": "VIP bonus (Copy)",
    "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": "68f78253046393414dec404e5442c140bb5db0393a7d1c389ff57bba71585501",
    "redactedPaths": []
  }
}
```

## Errors

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | The id is not 24 hexadecimal characters. Use the id exactly as returned. |
| 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. |

### 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. |
| 409 | `VERSION_CONFLICT` | You sent an `If-Match` that is not the current revision. Re-read and duplicate again. |
| 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. The copy is written after its steps, so a failure leaves nothing half-created; re-read the list before retrying. |
| 503 | `LOCK_UNAVAILABLE` | The write lock store did not answer, so nothing was written. Retry shortly. |

**Retries:** Duplicating is not idempotent: two accepted calls create two copies. A retry after a timeout can therefore leave two workflows named `<name> (Copy)`. Check the list for the extra copy and delete it.

## Next step

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