---
title: "Update an enrollment (state, lead identity, scheduling)"
method: PATCH
path: "/enrollments/{id}"
tags: ["Cadences"]
---

# Update an enrollment (state, lead identity, scheduling)

`PATCH /enrollments/{id}`

## Request

- Base URL: `https://anjal.app`
- URL: `https://anjal.app/enrollments/{id}`
- Auth: API key in header `X-API-Key`

Two distinct use cases — both supported by this single endpoint:

### 1. Pause / resume / unenroll

Pass `state` to move the enrollment between lifecycle states.
`active` / `paused` / `unenrolled` are the values the runner
respects. Terminal states (`completed`, `replied`, `accepted`,
`bounced`) are set by the runner itself based on signals + the
cadence's `exitOnReply` setting — don't write to them directly.

### 2. Fix-up bad enroll-time payload

Use case: a row was enrolled with the wrong shape (missing
`leadLinkedinUrl`, wrong `leadEmail`, etc.) and is now stuck on
a step with a `NO_TARGET` / `NO_INBOX` error. Patch the
identity fields and the runner picks up the corrected row on
its next tick — no need to unenroll + re-enroll (which would
lose `currentStep`, `lastStepFiredAt`, the step_runs audit, and
any `leadExternalId` joins your side has against the
enrollment).

**Merge semantics**: only fields present in the request body
are updated. Omitted fields stay as-is. Pass explicit `null` to
clear a column (where the column is nullable).

**Patchable identity fields**: `leadName`, `leadEmail`,
`leadLinkedinUrl`, `leadProfileUrn`, `leadExternalId`,
`inboxId`, `sessionId`. The runner resolves
`leadLinkedinUrl` → `leadProfileUrn` automatically when the
next LinkedIn step fires — you do not need to look up the URN
separately before patching.

### `?retryNow=true` query param

When fixing a stuck enrollment, also pass `?retryNow=true` to
reset `next_step_due_at = NOW()` so the runner re-attempts on
the next tick (~1 min) instead of waiting out the post-failure
1h backoff. Opt-in so routine patches (typo fix on a name)
don't unnecessarily trigger an immediate retry.

Successful retry automatically clears `lastError` from the
list endpoint's per-row summary — the `lastError` query
ignores failed step_runs that occurred before the most recent
successful run.

### Terminal-state guard

Patching lead identity is rejected (`409 ENROLLMENT_TERMINAL_STATE`)
when the enrollment is in `completed` / `replied` / `accepted` /
`bounced` / `unenrolled`. There's no use case for "fix a lead
on a completed cadence" — the runner won't re-run it. If you
genuinely want to resume, PATCH `state: 'active'` first, then
patch identity in a second call.

### `leadExternalId` conflict

Changing `leadExternalId` to a value another enrollment in the
same cadence already uses returns `409 LEAD_EXTERNAL_ID_CONFLICT`
with the conflicting value in the response body. The
`(cadence_id, lead_external_id)` unique constraint is what
makes the enroll endpoint idempotent — you can't violate it
via PATCH either.

### Worked example: fix-up flow

Before — list endpoint shows:

```json
{
  "id": "01HZ…",
  "leadName": null,
  "leadLinkedinUrl": null,
  "state": "active",
  "currentStep": 0,
  "stepsCompleted": 0,
  "lastError": {
    "code": "NO_TARGET",
    "message": "no_linkedin_url",
    "atStepIndex": 0,
    "firedAt": "2026-05-19T14:00:00Z"
  },
  "nextStepDueAt": "2026-05-19T15:00:00Z"
}
```

Patch request:

```
PATCH /v1/enrollments/01HZ…?retryNow=true
Content-Type: application/json

{
  "leadName": "Jane Doe",
  "leadLinkedinUrl": "https://www.linkedin.com/in/jane-doe-1a2b3c/",
  "leadEmail": "jane@acme.com"
}
```

Response (immediate — `lastError` clears on the next list call
after the runner ticks successfully):

```json
{
  "ok": true,
  "enrollment": {
    "id": "01HZ…",
    "leadName": "Jane Doe",
    "leadLinkedinUrl": "https://www.linkedin.com/in/jane-doe-1a2b3c/",
    "leadEmail": "jane@acme.com",
    "state": "active",
    "currentStep": 0,
    "nextStepDueAt": "2026-05-19T14:32:11Z",
    "updatedAt": "2026-05-19T14:32:11Z"
  }
}
```

## Path parameters

- `id` string, uuid, required

## Query parameters

- `retryNow` boolean

## Request body

- object
  - `state` 'active' | 'paused' | 'unenrolled': Move between lifecycle states. Terminal states are set by the runner; don't write to them.
  - `exitReason` string, nullable
  - `leadName` string, nullable: Merge semantics: omit to keep, null to clear, string to set.
  - `leadEmail` string, email, nullable
  - `leadLinkedinUrl` string, uri, nullable
  - `leadProfileUrn` string, nullable: `urn:li:fsd_profile:…`. Optional — the runner resolves this from `leadLinkedinUrl` automatically when the next LinkedIn step fires.
  - `leadExternalId` string: Idempotency key. Changing it to a value another enrollment in this cadence already uses returns 409 LEAD_EXTERNAL_ID_CONFLICT.
  - `inboxId` string, uuid, nullable
  - `sessionId` string, uuid, nullable
  - `currentStep` integer: Manual cursor advance/rewind. Use sparingly — the runner manages this automatically. Legitimate cases: 1. Bypass an `after_accept`-gated step when the lead accepted out-of-band (rare). 2. Replay from a step that fired against the wrong inbox / session, after fixing the enrollment. 3. Abandon a step stuck on an external signal that won't materialise. Range: `[0, stepCount]` inclusive. Passing `stepCount` transitions the enrollment to `state: completed` (`exit_reason: manually_completed`, `next_step_due_at` cleared). Out-of-range values return `400 STEP_OUT_OF_RANGE` with the cadence's actual step count. Audit: every manual advance inserts a `linkedin_cadence_step_runs` row with `status: skipped`, `skip_reason: manual_advance_via_patch`, anchored to the step the cursor was on. Visible in `GET /v1/enrollments/{id}`'s `stepRuns[]` so anyone auditing the cadence sees the jump. Risk: rewinding to an earlier email step may cause a duplicate send when the runner re-fires. LinkedIn dedups invitations server-side; SMTP does not. Pair with `state: paused` first if you want to inspect before letting the runner re-fire.

## Response `200`

Updated. Returns the full enrollment in the same shape as `GET /enrollments/{id}`'s `enrollment` field.

## Other responses

- `400`: `STEP_OUT_OF_RANGE` — `currentStep` was outside `[0, stepCount]` for this cadence. Response body includes `attempted` (the value you sent) and `stepCount` (the cadence's actual step count) so you can correct without a follow-up GET.
- `404`: Enrollment not found, or it belongs to a different tenant.
- `409`: Either `ENROLLMENT_TERMINAL_STATE` (cadence is completed/replied/ accepted/bounced/unenrolled and lead identity can't be patched on a terminal row) or `LEAD_EXTERNAL_ID_CONFLICT` (the new leadExternalId is already in use by another enrollment in this cadence).

---

[API](https://skmtc.dev/anjal/apis/anjal-api.md) · [All operations](https://skmtc.dev/anjal/apis/anjal-api/llms.txt) · [OpenAPI document](https://skmtc.dev/anjal/apis/anjal-api/revisions/a4044778e294?raw)
