# Duplicate a workflow

**POST** `/api/workflows/{id}/duplicate`

Copies a workflow’s draft into a new draft called “… (Copy)”.

Needs [an API token](https://docs.gorunner.io/api/authentication#scopes) with the **Write** permission for workflows (`workflow:write`).

## Parameters

### Path

- `id` · Required · UUID: The workflow’s ID.

### Headers

- `Idempotency-Key` · Optional · string: Up to 255 characters. The same request with the same key within 24 hours answers the first result again instead of making a second copy. See Idempotency.

## What is copied

Copied: the draft tree, the description, the tags, the folder, each step’s app version and connection, and the sensitive variables.

Not copied: versions, runs, test data and pins. The copy stays a draft until you publish it, and gets its own webhook address when you first update it or publish it.

It takes no body. Rename the copy with [Update a workflow’s draft](https://docs.gorunner.io/api/workflows/update-workflow). A name that would pass 255 characters is shortened and ends in “…”.

## Responses

- 201 Created

  The new copy, a draft, with its `definitionRev`.

- 400 `VALIDATION_ERROR`

  `id` isn’t a UUID.

- 403 `FORBIDDEN`

  The token lacks the **Write** permission for workflows.

- 404 `NOT_FOUND`

  No workflow with this ID in the token’s workspace.

- 409 `CONFLICT`

  A request with this `Idempotency-Key` is still in progress, or its answer was too large to keep. Try again shortly.

- 422 `VALIDATION_ERROR`

  This `Idempotency-Key` was already used with a different request.

- 422 `PIECE_NOT_ALLOWED`

  The draft has a step on an app this workspace may not use. A copy is a new workflow, so it can’t have one.

- 500 `INTERNAL_ERROR`

  The copy wasn’t made. Try again.

- 503 `PIECES_UNAVAILABLE`

  GoRunner couldn’t check which apps this workspace may use. Nothing changed. Try again.

Any call can also answer: [`401 UNAUTHORIZED`](https://docs.gorunner.io/errors#UNAUTHORIZED) · [`401 TOKEN_ENDED`](https://docs.gorunner.io/errors#TOKEN_ENDED) · [`403 TOKEN_SURFACE_OFF`](https://docs.gorunner.io/errors#TOKEN_SURFACE_OFF) · [`403 FEATURE_OFF`](https://docs.gorunner.io/errors#FEATURE_OFF) · [`403 EMBED_NOT_ALLOWED`](https://docs.gorunner.io/errors#EMBED_NOT_ALLOWED) · [`403 EMBED_NOT_AVAILABLE`](https://docs.gorunner.io/errors#EMBED_NOT_AVAILABLE) · [`403 ACCOUNT_SUSPENDED`](https://docs.gorunner.io/errors#ACCOUNT_SUSPENDED) · [`413 PAYLOAD_TOO_LARGE`](https://docs.gorunner.io/errors#PAYLOAD_TOO_LARGE) · [`415 INVALID_CONTENT_TYPE`](https://docs.gorunner.io/errors#INVALID_CONTENT_TYPE) · [`429 RATE_LIMITED`](https://docs.gorunner.io/errors#RATE_LIMITED) · [`503 BUSY`](https://docs.gorunner.io/errors#BUSY) · [`503 PAUSED`](https://docs.gorunner.io/errors#PAUSED) · [`503 SERVICE_UNAVAILABLE`](https://docs.gorunner.io/errors#SERVICE_UNAVAILABLE). See [Errors](https://docs.gorunner.io/api/errors#by-status).

Request

**curl**

```bash
GORUNNER="https://api.gorunner.io"
GORUNNER_TOKEN="grt_…"  # needs Write for workflows
WORKFLOW_ID="your-workflow-id"

URL="$GORUNNER/api/workflows/$WORKFLOW_ID/duplicate"
curl -X POST "$URL" \
  -H "Authorization: Bearer $GORUNNER_TOKEN"
```

**JavaScript**

```js
const GORUNNER = "https://api.gorunner.io";
const TOKEN = process.env.GORUNNER_TOKEN;
const WORKFLOW_ID = process.env.WORKFLOW_ID;

const res = await fetch(
  `${GORUNNER}/api/workflows/${WORKFLOW_ID}/` +
    `duplicate`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}` },
  },
);
const { data } = await res.json();
```

**Python**

```python
import os, requests

GORUNNER = "https://api.gorunner.io"
TOKEN = os.environ["GORUNNER_TOKEN"]
WORKFLOW_ID = os.environ["WORKFLOW_ID"]

res = requests.post(
    f"{GORUNNER}/api/workflows/{WORKFLOW_ID}/"
    f"duplicate",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
data = res.json()["data"]
```

Response · 201 Created, shortened

```json
{
  "data": {
    "id": "6e9a1c3f-8b2d-4a57-9e0c-3f7b5d1a8c42",
    "name": "Order intake (Copy)",
    "status": "draft",
    "definitionRev": 1,
    "hasUnpublishedChanges": false,
    "folderId": "7a2c9e41-5b3d-4f08-a6e1-2d9c4b7f0e13",
    "tags": [
      "orders"
    ],
    "createdAt": "2026-10-20T08:12:00Z"
  }
}
```

**Every field**

It takes no body.

### Response · 201 Created

- `data` · object: A workflow row. `definition` is always the DRAFT — the tree the editor holds. What production actually runs is the snapshot named by `latestVersionId`, which only changes when you publish.
  - `id` · UUID
  - `name` · string
  - `description` · string
  - `definition` · object: The workflow definition, and the single most important shape in this API. A workflow definition: its fields are in [Workflow definition](https://docs.gorunner.io/api/workflow-definition).
  - `status` · string: `draft` = never published. `active` = live, triggers registered and the webhook URL reachable. `paused` = was live, now switched off. Server-owned: it moves only through publish/unpublish, never through a create or update body. One of `draft`, `active`, `paused`.
  - `triggerType` · string: Mirrors `definition.trigger.type` for dispatch. Derived server-side from the definition by every update and publish, so treat it as read-only in practice. A create that sends its own value keeps it until then, unchecked. One of `manual`, `cron`, `webhook`, `callable`, `event`, `error`, `run_finished`, `poll`.
  - `cronExpression` · string or null: Nothing derives or reads this: the schedule runs from the trigger’s own `config`, and `nextRunAt` projects from it too. Null unless a caller wrote one with `PUT /api/workflows/{id}`.
  - `webhookToken` · string or null: The secret segment of the public webhook URL. Made by the first update of a webhook workflow, or at publish; absent until then (so absent in the answers of create, duplicate and import). Returned ONLY to a caller holding `workflow:run`: by `GET /api/workflows/{id}` and in the answers of update, publish, unpublish and restore (rotate answers it to its caller). It is never present on a list row, an export or a version snapshot. It is a bearer capability against an unauthenticated endpoint — possessing it is the ability to fire this workflow in production. Rotate it with `POST /api/workflows/{id}/webhook/rotate`.
  - `latestVersionId` · UUID or null: The published snapshot production executes. Absent means nothing has ever been published.
  - `definitionRev` · integer: Optimistic-lock revision, bumped by every definition save. Populated by `GET /api/workflows/{id}`, by the update response and by the answers of create, duplicate and import; absent (0) on list rows. Send it back as `expectedRev` on an update to refuse a clobbering write.
  - `hasUnpublishedChanges` · boolean: The draft has moved ahead of the published version. Computed on the single-workflow read and on every list row; always `false` for a workflow never published.
  - `folderId` · UUID or null: Absent/null = uncategorized. Changed only through `PUT /api/workflows/{id}/folder`.
  - `tags` · array of strings: Changed only through `PUT /api/workflows/{id}/tags` or the bulk `addTags` operation — never through the workflow update body.
  - `testPayloads` · object or null: The saved test-fixture library, stored outside the definition so publish and version restore never reset it.
  - `pinnedData` · object or null: The pin library (`{"pins": {"<stepName>": {"data": …, "pinnedAt": …, "configHash": …, "staleAt": …, "pieceId": …}}}`). A pinned step emits its fixture instead of executing, and only on test runs. `configHash`, `staleAt` and `pieceId` are the server’s: a step whose config changed keeps its pin marked stale, and a stale pin is not replayed (the step runs); a step whose app was replaced loses it.
  - `workspaceId` · UUID: Stamped from the credential’s scope; never accepted from a request body.
  - `orgId` · UUID
  - `createdBy` · UUID or null
  - `createdByName` · string: List rows only: the creator’s name, else their email address. Absent when the creator is unknown or not a member of the organization (staff who built it on a support visit, someone since removed), and on every row for an embedded person whose installation switched `presence` off (an embedded person is never told an address).
  - `createdAt` · time
  - `updatedAt` · time: When the workflow itself was last edited. Publishing, pausing, tagging and filing it are not edits and leave it alone.
  - `failing` · boolean: List rows only: the workflow is live (`status: active`) and its latest FINISHED production run (one that worked or failed) failed. A later run that is waiting, running, held or was cancelled changes nothing, and test runs never count. A paused or draft workflow is never failing.
  - `failingSince` · time: Only on a failing row: when the first production run failed after the last one that worked (runs cancelled, held or running in between neither break nor begin the streak). Left out for a caller without `run:read`.
  - `failingRun` · object: Only on a failing row: the run that decided it, its latest finished production run, with why it failed. It is `lastRun` unless a later run is waiting, running, held or was cancelled. Without `run:read` it carries `status` and `startedAt` alone.
    - `id` · UUID
    - `status` · string: One of `failed`.
    - `startedAt` · time
    - `finishedAt` · time or null
    - `error` · object: Why it failed, as `lastRun.error` says it.
      - `step` · string or null
      - `stepKey` · string or null
      - `message` · string
  - `summary` · object: List rows only: what a list draws instead of the definition.
    - `trigger` · object or null: The chosen trigger; null while none is chosen.
      - `pieceId` · string
      - `actionId` · string
      - `displayName` · string
    - `pieces` · array of objects: The apps the workflow uses, each once, in walk order (the trigger first), with their display names.
      - `id` · string
      - `name` · string
    - `stepCount` · integer: Steps after the trigger, across branch arms, loop bodies and error paths.
    - `needsSetup` · integer: Steps (and a chosen trigger) the editor marks to configure: `valid: false`, or no app and action chosen.
  - `runs7d` · array of integers: List rows without `view=summary` only: counted runs per UTC day over the last seven days, oldest first (production runs that are not a sub-flow’s, cancelled runs left out).
  - `nextRunAt` · time: Next fire time of an active schedule workflow, projected from its cron in the trigger’s own timezone. Populated by the list endpoint.
  - `lastRun` · object or null: The most recent PRODUCTION run’s outcome (test runs never count). Populated on list rows only. Without `run:read` it carries `status` and `startedAt` alone.
    - `id` · UUID
    - `finishedAt` · time or null
    - `error` · object: Only when `status` is `failed`: why.
      - `step` · string or null: The display name of the step it failed at; null when the reason names no step.
      - `stepKey` · string or null
      - `message` · string: The reason’s first clause, at most 160 characters.
    - `status` · string: One of `pending`, `running`, `completed`, `failed`, `cancelled`, `paused`.
    - `startedAt` · time

Related: [Update a workflow’s draft](https://docs.gorunner.io/api/workflows/update-workflow) · [Export a workflow](https://docs.gorunner.io/api/workflows/export-workflow)

Source: https://docs.gorunner.io/api/workflows/duplicate-workflow
