# Move a lead's card

`PUT /v1/leads/{lead_id}/stage`

Moves the lead's pipeline card to another stage of its pipeline, the way a move on the board does.

**Scope:** `pipeline:write`

The card is the one `GET /v1/leads/{lead_id}` returns as `card`: for a member who may open only a follow-through card of the lead, that card, never the main card. `stage`, `expected_stage`, `previous_stage`, `error.current_stage` and `error.valid_stages` all refer to it. The move is recorded in the card's stage history and the pipeline's stage automations run exactly as when your team moves the card. A card already in `stage` answers `200` with `changed: false` whatever `expected_stage` says, so retrying after a timeout is safe.

Compare and set: send `expected_stage` with every automated move. The check and the move are one atomic write, so if someone moved the card since you read it, nothing changes and you get `409 stage_conflict` with the card's `current_stage`. Read the lead again and decide again; do not resend the same move blindly.

Human placement wins. A move not made for an active team member (no `acting_member_id`, or one that is not an active member) never overrides a person: if the card was last placed by a team member, `stage` is one only a person may place cards in (the AI follow-up stage, `ai-follow-up`), or the card is a follow-through card (only a person moves those), the request fails with `409 member_required` and nothing changes.

The lead must belong to a campaign the key may reach and be one the key's member can see in DialEcho, otherwise `404`.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `lead_id` | `UUID` | Yes | The lead, as `POST /v1/leads` returned it. |

## Body

**Content type:** `application/json`

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `stage` | `string` | Yes | Slug of the stage to move the card to, as `card.stage` and the pipeline board show slugs. It must be a stage of the card's pipeline. 1 to 200 characters after trimming. |
| `expected_stage` | `string` | No | Slug of the stage you believe the card is in now. If it is in any other stage nothing changes and the request fails with `409 stage_conflict`. Must not be blank. 1 to 200 characters after trimming. |
| `acting_member_id` | `UUID` | No | The team member you are moving the card for, for example the `member.id` a verified lead-action token returned. If it is an active member of your organization the move is recorded as theirs, made through the API, exactly as if they had moved the card on the board; otherwise it is recorded as a move by the API. |

## Response

**Status:** `200 OK`

| Name | Type | Description |
| --- | --- | --- |
| `lead_id` | `UUID` | The lead. |
| `pipeline_lead_id` | `UUID` | The card that was moved. |
| `stage` | `string` | The card's stage now (the `stage` you sent). |
| `previous_stage` | `string` | The stage the card was moved from; equals `stage` when nothing changed. |
| `changed` | `boolean` | `false` when the card was already in `stage`: nothing was recorded and no automation ran. |

## Example request

**curl**

```bash
curl -X PUT "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/stage" \
  -H "Authorization: Bearer dei_your_key" \
  -H "Content-Type: application/json" \
  -d '{
  "stage": "won",
  "expected_stage": "appointment",
  "acting_member_id": "ffffffff-0000-4000-8000-000000000001"
}'
```

**JavaScript**

```js
const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/stage", {
  method: "PUT",
  headers: {
    "Authorization": "Bearer dei_your_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "stage": "won",
    "expected_stage": "appointment",
    "acting_member_id": "ffffffff-0000-4000-8000-000000000001"
  }),
});
const data = await response.json();
```

## Example response

**Status:** `200 OK`

```json
{
  "lead_id": "aaaaaaaa-0000-4000-8000-000000000001",
  "pipeline_lead_id": "dddddddd-0000-4000-8000-000000000001",
  "stage": "won",
  "previous_stage": "appointment",
  "changed": true
}
```

## Errors

| HTTP | Code | When |
| --- | --- | --- |
| 403 | `insufficient_scope` | The key does not have `pipeline:write`. Checked before the body is validated. |
| 404 | `not_found` | The lead does not exist or the key cannot reach it. |
| 409 | `no_pipeline_card` | The lead has no pipeline card. Nothing was moved. |
| 409 | `stage_conflict` | The card is not in `expected_stage` (or moved while the request ran). `error.current_stage` is its stage now. Nothing was moved. |
| 409 | `member_required` | A move not made for an active team member was refused because a person placed the card, only a person may place cards in `stage`, or the card is a follow-through card. `error.current_stage` is its stage. Nothing was moved. |
| 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was written. |
| 422 | `unknown_stage` | `stage` is not a stage of the card's pipeline. `error.valid_stages` lists that pipeline's slugs in board order. |
| 422 | `invalid_request` | `lead_id` is not a UUID, or the body is invalid: `stage` missing or blank, a blank `expected_stage`, an `acting_member_id` that is not a UUID, or an unknown field. |

Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview.

## Related endpoints

- `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get)
- `GET /v1/campaigns/{campaign_id}/pipeline` · [Retrieve the pipeline](/api/reporting/pipeline-board)
- `POST /v1/lead-actions/verify` · [Verify a launch token](/api/lead-actions/verify)
