---
title: "Get an enrollment + its step-run history"
method: GET
path: "/enrollments/{id}"
tags: ["Cadences"]
---

# Get an enrollment + its step-run history

`GET /enrollments/{id}`

## Request

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

Returns the enrollment row plus its `stepRuns[]` history (last 50,
newest-first). Use this when you need per-step status — completed,
skipped, failed — to render a step-by-step ✅ timeline.

## Step run statuses

- `sent`     — step fired and LinkedIn / SMTP accepted the action.
               Render as a green checkmark.
- `skipped`  — step was evaluated but its `condition` didn't apply
               (e.g. `after_accept` step on an unaccepted invitation,
               or `never_replied` step when the lead replied).
               Normal cadence flow — render as a grey checkmark or
               skip-icon, NOT as an error.
- `failed`   — runner tried and got back `ok:false` from the
               downstream call. `errorCode` is set. The enrollment
               is deferred 1h and retried on the next tick — this
               is NOT a terminal state on its own.
- `queued`   — internal scheduling state. You'll rarely see this in
               practice; included for completeness.

## Error codes you'll see in `errorCode`

Set by `api/src/lib/cadences/runner.ts`. The catalogue:

- `NO_SESSION_AVAILABLE`      — workspace has zero connected
                                LinkedIn sessions. Customer needs
                                to connect one at /dashboard/linkedin.
- `WORKSPACE_QUOTA_EXHAUSTED` — sessions exist but all at the
                                per-action daily cap (e.g. 30
                                connection_requests/day per
                                session). Resets at midnight UTC,
                                or add more sessions.
- `PINNED_SESSION_UNUSABLE`   — `enrollment.sessionId` was set
                                manually but that session is
                                revoked / not-active / capped.
                                Customer should reconnect or
                                repin.
- `NO_TARGET`                 — `leadLinkedinUrl` (for LinkedIn
                                steps) or `leadEmail` (for email
                                steps) was missing at enrollment.
- `NO_INBOX`                  — email step but no `inboxId` on the
                                enrollment or cadence.
- `DRAFT_WEBHOOK_FAILED`      — step has `draftStrategy: "webhook"`
                                and the customer's draft handler
                                timed out (>30s), 5xx'd, returned
                                non-JSON, returned an invalid
                                defer, or returned no `body`.
                                `errorMessage` carries the upstream
                                detail (which status code, "no
                                body in response", etc). The
                                step defers 1h and retries —
                                which means a flaky handler will
                                keep getting opportunities to
                                succeed. To stop retries, mark
                                the enrollment `state: "paused"`
                                or unenroll.
- `AI_STRATEGY_REMOVED`       — pre-existing step still on
                                `draftStrategy: "ai"` (removed
                                2026-05-19). Update the step to
                                `static` (with `staticBody`) or
                                `webhook` (with the cadence's
                                `webhookUrl` set to your draft
                                handler).
- `send_failed`               — downstream LinkedIn / SMTP returned
                                an error. `errorMessage` carries the
                                upstream detail.

## Worked example response

```json
{
  "ok": true,
  "enrollment": {
    "id": "01HZ…",
    "cadenceId": "01HY…",
    "leadExternalId": "delegated:lead:abc-123",
    "leadName": "Jane Doe",
    "leadLinkedinUrl": "https://www.linkedin.com/in/jane-doe-1a2b3c/",
    "leadProfileUrn": "urn:li:fsd_profile:ACoAAA…aaaa",
    "sessionId": "01HX…",
    "currentStep": 3,
    "state": "active",
    "nextStepDueAt": "2026-05-21T14:00:00Z",
    "lastStepFiredAt": "2026-05-19T14:00:00Z",
    "enrolledAt": "2026-05-18T10:30:00Z",
    "updatedAt": "2026-05-19T14:00:00Z"
  },
  "stepRuns": [
    {
      "id": "01HZ…",
      "stepIndex": 2,
      "stepKind": "linkedin_message",
      "status": "sent",
      "channelUrn": "urn:li:msg_message:(…)",
      "renderedSubject": null,
      "renderedText": "Hey Jane — saw your post about cold outbound…",
      "errorMessage": null,
      "errorCode": null,
      "skipReason": null,
      "firedAt": "2026-05-19T14:00:00Z"
    },
    {
      "id": "01HZ…",
      "stepIndex": 1,
      "stepKind": "wait",
      "status": "sent",
      "channelUrn": null,
      "renderedSubject": null,
      "renderedText": null,
      "errorMessage": null,
      "errorCode": null,
      "skipReason": null,
      "firedAt": "2026-05-19T13:00:00Z"
    },
    {
      "id": "01HZ…",
      "stepIndex": 0,
      "stepKind": "linkedin_connect",
      "status": "sent",
      "channelUrn": "urn:li:fsd_profile:ACoAAA…aaaa",
      "renderedSubject": null,
      "renderedText": "Hi Jane — common interest in outbound; thought I'd connect.",
      "errorMessage": null,
      "errorCode": null,
      "skipReason": null,
      "firedAt": "2026-05-18T10:30:00Z"
    }
  ]
}
```

## Rendering ✅ progress

```ts
const r = await mam.getEnrollment({ id });
const sentByIndex = new Map<number, true>();
const skippedByIndex = new Map<number, true>();
let lastFailure: typeof r.stepRuns[number] | null = null;

for (const run of r.stepRuns) {
  if (run.status === "sent") sentByIndex.set(run.stepIndex, true);
  else if (run.status === "skipped") skippedByIndex.set(run.stepIndex, true);
  else if (run.status === "failed" && !lastFailure) lastFailure = run;
}

// For each step in cadence.steps[]:
//   if (sentByIndex.has(i))    → ✅ done
//   else if (skippedByIndex.has(i)) → ⊘ skipped (condition not met)
//   else if (i === enrollment.currentStep && lastFailure) → ⚠ blocked
//   else if (i === enrollment.currentStep) → ⏳ pending
//   else → ◯ upcoming
```

## Path parameters

- `id` string, uuid, required

## Response `200`

Enrollment + step runs.

- object
  - `ok` boolean
  - `enrollment` object: Same shape as items in `/cadences/{id}/enrollments` (minus the `stepsCompleted` / `lastError` summary — drill into `stepRuns[]` below instead).
  - `runnerState` object: Read-only diagnostic — answers "would the runner fire this row right now? if not, why?". Same logic the runner uses to pick enrollments (`pickDueEnrollments` + `evaluateCondition`), replicated read-only so callers can self-diagnose without poking the runner. Use this BEFORE filing a support ticket. If `pickable: true` and the row isn't firing, the runner tick interval is 5 minutes — `maxExpectedWaitSeconds` carries the upper bound. Use `POST /v1/cadences/{id}/run-now` to force a tick. If `pickable: false`, `reasons[]` lists every blocking condition with the exact PATCH curl needed to unblock. `details` exposes the underlying values (state, scheduling, condition evaluation) for deeper inspection.
    - `pickable` boolean: True iff the runner would pick this enrollment on its next tick.
    - `reasons` string[]: Empty when pickable=true. Each item is a human-readable explanation of a blocking condition, including the corrective curl when applicable.
    - `details` object: Underlying decision inputs — state, scheduling, condition evaluation, draft strategy sanity.
      - `state` string: enrollment.state
      - `cadenceEnabled` boolean
      - `nextStepDueAt` string, date-time, nullable
      - `nextStepDueInSeconds` integer, nullable: Negative = overdue, positive = future.
      - `currentStep` integer
      - `stepCount` integer
      - `currentStepKind` string, nullable
      - `currentStepCondition` string, nullable
      - `currentStepDraftStrategy` string, nullable
      - `conditionOutcome` 'proceed' | 'skip-step' | 'wait', nullable
      - `conditionReason` string, nullable
      - `runnerTickIntervalSeconds` integer: How often the runner ticks (currently 300s/5min).
      - `maxExpectedWaitSeconds` integer, nullable: Upper bound on time-to-fire when pickable=true. Null when blocked (no deterministic ETA).
  - `stepRuns` object[]: Last 50 step runs for this enrollment, newest-first.
    - `id` string, uuid
    - `stepIndex` integer: 0-based; matches cadence.steps[stepIndex].
    - `stepKind` 'linkedin_connect' | 'linkedin_message' | 'email' | 'wait'
    - `status` 'sent' | 'skipped' | 'failed' | 'queued'
    - `channelUrn` string, nullable: For LinkedIn steps: invitation URN / message URN / profile URN. For email: the message-id.
    - `renderedSubject` string, nullable: Email subject (email steps only).
    - `renderedText` string, nullable: The body that was actually sent — AI-generated or static, post-rendering.
    - `errorMessage` string, nullable
    - `errorCode` string, nullable: See description above for the catalogue.
    - `skipReason` string, nullable: Why a `skipped`-status run was skipped (e.g. condition_not_met).
    - `firedAt` string, date-time

## Other responses

- `404`: Not found.

---

[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)
