# Update a workflow’s draft

**PUT** `/api/workflows/{id}`

Changes a workflow’s draft. What runs live changes only when you publish.

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.

### Body

- `name` · Optional · string: Up to 255 characters. Empty keeps the name; spaces alone are refused.
- `description` · Optional · string: Left out keeps it; `""` clears it.
- `definition` · Optional · object: The whole tree: the trigger, then each step through `nextAction`. See [Workflow definition](https://docs.gorunner.io/api/workflow-definition).
- `expectedRev` · Optional · integer: The `definitionRev` you read. If someone saved since, the update is refused with 409 instead of overwriting. Left out, `null`, 0 or below: no check.

**What are the rarely used parameters?**

- `tab` · Optional · string: Only for the editor: its tab’s id. Leave it out.
- `testPayloads` · Optional · object: The saved test data. Left out keeps it.
- `pinnedData` · Optional · object: Pinned step output: `{"pins": {"step": {"data": …}}}`. A pinned step replays its data instead of running, on test runs only; production always runs. Up to 2 MB a pin. Left out keeps it.
- `triggerType` · Optional · string: Leave it out. GoRunner reads it from the trigger.
- `cronExpression` · Optional · string or null: Leave it out. Nothing reads it: a schedule runs from its trigger’s `config`.

## What you send replaces what

A field you leave out stays as it is. `definition` replaces the whole tree: read the workflow, change the tree and send it all back.

Tags and the folder have their own calls: [Set a workflow’s tags](https://docs.gorunner.io/api/workflows/replace-workflow-tags) and [Move a workflow to a folder](https://docs.gorunner.io/api/workflows/move-workflow-to-folder). `status` changes only by publishing. Fields like these are ignored here, but one of the wrong type, such as `"tags": "x"`, is a 400; sending back the whole workflow you read is fine.

A step without a `pieceVersion` is pinned to the app version it runs today, and a definition without `schemaVersion` is stored as sent and reads as 0: it is still a version 2 tree, and its export imports. The first update of a webhook workflow makes its `webhookToken`. A pin whose step changed is marked `staleAt` and no longer used. A pin whose step is removed, or now runs another app, is deleted.

## Responses

- 200 OK

  The workflow after the change, with the next `definitionRev`.

- 400 `VALIDATION_ERROR`

  `id` isn’t a UUID, the body isn’t JSON, or the name is over 255 characters or only spaces.

- 400 `VALIDATION_ERROR`

  The tree has a key it doesn’t take, no named trigger, two steps with one name, a new step name or display name over 255 characters, a `type` other than PIECE, CODE, BRANCH or LOOP, a branch step with no branches, or a new condition operator that doesn’t exist (the message lists the ones that do). Or a pin is over 2 MB.

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

  `expectedRev` is out of date: someone saved since. Read the workflow again and redo your change.

- 409 `CONFLICT`

  Another save to this workflow took too long. Send the same request again.

- 409 `EDITING_ELSEWHERE`

  Only with `tab`: another editor tab holds the workflow. Nothing was saved.

- 422 `VALIDATION_ERROR`

  The workflow is live, and the new tree names an app, action or trigger this installation can’t run. The message names the step.

- 422 `PIECE_NOT_ALLOWED`

  The tree adds a step on an app this workspace may not use. Steps already in the draft may stay.

- 500 `INTERNAL_ERROR`

  Nothing was saved. 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"

curl -X PUT "$GORUNNER/api/workflows/$WORKFLOW_ID" \
  -H "Authorization: Bearer $GORUNNER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order intake",
    "expectedRev": 7,
    "definition": {
      "schemaVersion": 2,
      "trigger": {
        "name": "trigger",
        "type": "webhook",
        "pieceId": "webhook",
        "actionId": "catch_webhook",
        "config": {
          "methods": [
            "POST"
          ]
        },
        "valid": true
      },
      "settings": {
        "timeoutSeconds": 120
      }
    }
  }'
```

**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}`,
  {
    method: "PUT",
    headers: {
        Authorization: `Bearer ${TOKEN}`,
        "Content-Type": "application/json",
      },
    body: JSON.stringify({
        name: "Order intake",
        expectedRev: 7,
        definition: {
          schemaVersion: 2,
          trigger: {
            name: "trigger",
            type: "webhook",
            pieceId: "webhook",
            actionId: "catch_webhook",
            config: {
              methods: ["POST"],
            },
            valid: true,
          },
          settings: { timeoutSeconds: 120 },
        },
      }),
  },
);
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.put(
    f"{GORUNNER}/api/workflows/{WORKFLOW_ID}",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={
      "name": "Order intake",
      "expectedRev": 7,
      "definition": {
        "schemaVersion": 2,
        "trigger": {
          "name": "trigger",
          "type": "webhook",
          "pieceId": "webhook",
          "actionId": "catch_webhook",
          "config": {
            "methods": ["POST"],
          },
          "valid": True,
        },
        "settings": {"timeoutSeconds": 120},
      },
    },
)
data = res.json()["data"]
```

Response · 200 OK, shortened

```json
{
  "data": {
    "id": "3b1e5c2a-7d4f-4e8b-a1c6-5f2d9b0e7a31",
    "name": "Order intake",
    "definition": {
      "schemaVersion": 2,
      "trigger": { … },
      "settings": {
        "timeoutSeconds": 120
      }
    },
    "status": "active",
    "definitionRev": 8,
    "hasUnpublishedChanges": true,
    "updatedAt": "2026-10-24T16:40:00Z"
  }
}
```

**Every field**

### Body

- `name` · Optional · string: Trimmed. An empty string is ignored (the existing name is kept), not applied; spaces alone are a 400.
- `description` · Optional · string: Send `""` to clear it; omit to keep it.
- `definition` · Optional · 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).
- `triggerType` · Optional · string: Overridden whenever the definition carries a trigger — it is re-derived from `definition.trigger` after the merge, so the value sent has no lasting effect. One of `manual`, `cron`, `webhook`, `callable`, `event`, `error`, `run_finished`, `poll`.
- `expectedRev` · Optional · integer: The `definitionRev` you read. When it no longer matches the stored row the save is refused with 409 instead of overwriting whoever saved in between. Omit it, or send null, 0 or a negative number, for an unconditional last-write-wins save.
- `tab` · Optional · string: The builder’s tab id (the opaque `tab` its presence beat sends to `POST /api/workflows/{id}/presence`). With it, the save is refused with 409 `EDITING_ELSEWHERE` while the workflow’s advisory editing lock is held by another tab, the same person’s or someone else’s, so a tab that has not yet heard it lost the lock cannot write under the one that holds it. A free lock, or one this tab holds, lets the save through, and so does presence that cannot be read. Omit it (API clients, MCP and the partner API do): the editing lock is then not consulted, as before.
- `testPayloads` · Optional · object: The canvas test-fixture library, stored outside the definition so publishing or restoring a version never resets it. Absent = keep.
- `pinnedData` · Optional · object: The pin library: `{"pins": {"<stepName>": {"data": {…}}}}`. A pinned step emits its fixture INSTEAD of executing, and only on `environment=test` runs — production always executes for real. One pin is capped at 2 MB. Absent = keep. The server re-stamps the library on every save of the definition (this update, version restore, the assistant): a pin whose step config has changed is flagged `staleAt`, not deleted, and is never replayed; a pin whose step is removed, or now runs another app, is deleted with it, so a step that later takes that name starts with no pin.
- `cronExpression` · Optional · string or null: Stored and answered as sent, and read by nothing: the schedule runs from the trigger’s own `config`, and no write derives this. Leave it out.

### Response · 200 OK

- `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: [Get a workflow](https://docs.gorunner.io/api/workflows/get-workflow) · [Check a definition](https://docs.gorunner.io/api/workflows/validate-workflow-definition) · [Publish a workflow](https://docs.gorunner.io/api/publish/publish-workflow) · [Workflow definition](https://docs.gorunner.io/api/workflow-definition)

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