Cadences

Update an enrollment (state, lead identity, scheduling)

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:

{
  "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"
  }
}
patch/enrollments/{id}

Request

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

Path parameters

idstring uuid required

Query parameters

retryNowboolean

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

state'active' | 'paused' | 'unenrolled'

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

exitReasonstring nullable
leadNamestring nullable

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

leadEmailstring email nullable
leadLinkedinUrlstring uri nullable
leadProfileUrnstring nullable

urn:li:fsd_profile:…. Optional — the runner resolves this from leadLinkedinUrl automatically when the next LinkedIn step fires.

leadExternalIdstring

Idempotency key. Changing it to a value another enrollment in this cadence already uses returns 409 LEAD_EXTERNAL_ID_CONFLICT.

inboxIdstring uuid nullable
sessionIdstring uuid nullable
currentStepinteger

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

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

Changes

No recorded changes to this operation across all 1 revision of this API.