All endpoints

Update an enrollment (state, lead identity, scheduling)

PATCHhttps://myagentmail.com/enrollments/{id}

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 leadLinkedinUrlleadProfileUrn 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:

{
  "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):

{
  "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
required
string

Query parameters

retryNow
boolean

When true, also resets `next_step_due_at = NOW()` so the runner picks the enrollment up on the next tick instead of waiting out the post-failure 1h backoff.

Request body

Content-Type: application/json

state
"active" | "paused" | "unenrolled"

Move between lifecycle states. Terminal states are set by the runner; don't write to them.

enum: "active" | "paused" | "unenrolled"

exitReason
string
leadName
string

Merge semantics: omit to keep, null to clear, string to set.

leadEmail
string

format: email

leadLinkedinUrl
string

format: uri

leadProfileUrn
string

`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

format: uuid

sessionId
string

format: uuid

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.

Responses

200Updated. Returns the full enrollment in the same shape as `GET /enrollments/{id}`'s `enrollment` field.
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.
404Enrollment not found, or it belongs to a different tenant.
409application/jsonEither `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).
error
string
code
"ENROLLMENT_TERMINAL_STATE" | "LEAD_EXTERNAL_ID_CONFLICT"

enum: "ENROLLMENT_TERMINAL_STATE" | "LEAD_EXTERNAL_ID_CONFLICT"

state
string

On ENROLLMENT_TERMINAL_STATE: the current state blocking the patch.

conflictingExternalId
string

On LEAD_EXTERNAL_ID_CONFLICT: the leadExternalId that's already in use.

hint
string

Human-readable next-step suggestion.

Authentication

Send your API key in the X-API-Key header (or Authorization: Bearer <key>). Any prefix accepted by this endpoint — tk_, wk_, ak_, or sa_ — is documented in the key prefix table.