> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stardex.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Enrolling people in campaigns

> How to prepare, check and start a campaign enrollment through the API

A campaign is a series of steps (emails and tasks) sent from a team member's mailbox. You create and edit campaigns with the `/v1/campaigns` endpoints, and you enroll people in two calls: **prepare**, then **start**. Nothing is enrolled or sent until you start.

## Permissions

API keys need these permissions, which an admin sets in Settings > API access:

| Permission | Allows |
| - | - |
| `campaigns:read` | Reading campaigns, mailboxes, enrollment batches and enrollees |
| `campaigns:write` | Creating and editing campaigns and their steps |
| `campaigns:enroll` | Preparing, changing, starting, discarding and stopping enrollments, and editing an enrollee's upcoming steps. Archiving a campaign needs it too, with `campaigns:write`, because archiving stops everyone in it |

Only the API key or person who prepared an enrollment batch can change, start or discard it. Anyone with campaign access can read it or stop the people it enrolled.

An API key sends as a team member you name in `sender_team_member_id`. That person needs access to campaigns, and the mailbox in `from_email` must be theirs or shared with them. `GET /v1/campaigns/mailboxes?sender_team_member_id=...` lists the mailboxes they can send from.

## 1. Prepare

```bash theme={null}
POST /v1/campaigns/{id}/enrollment-batches
```

```json theme={null}
{
  "sender_team_member_id": "7c1e4a9b-3d6f-4b2e-8a5c-9f1d3b7e5a20",
  "from_email": "riya@acmetalent.com",
  "job_id": "b21d7e4a-5c9f-4e3b-a1d6-8f2c4e7b9a13",
  "start_mode": "next_window",
  "variable_values": { "custom": "We placed 3 engineers at Monzo last year." },
  "recipients": [
    { "person_id": "2f6b9d1e-4a8c-4e3f-b7d2-1c5a9e3b7f40", "variable_values": { "personalization_line": "Loved your PyCon talk." } },
    { "person_id": "6a3e8c2f-9b1d-4f7a-a5c3-8e2b6d4f1a73" }
  ]
}
```

This stores a draft **enrollment batch**. Every person is checked and gets a status:

| Status | Meaning |
| - | - |
| `ready` | Will be enrolled at start |
| `needs_input` | Fixable: a missing variable, no email, or a block you can override with a reason |
| `blocked` | Can't be enrolled: unsubscribed, bounced, marked do not contact, already in this campaign at the same email, or no longer in your workspace |
| `excluded` | You left them out |
| `preparing` | AI variables are still being written |

Each person not ready has `issues` with a `code` and a `message` you can show to a user. Warnings such as `enrolled_in_other_campaign`, `previously_in_campaign` (they were in this campaign before and were stopped) or `likely_bounce` don't stop anyone. Each person also lists the `overrides` they are enrolled despite, with the `override_reason`.

A batch holds up to your workspace's recipient limit (50 unless your admin changed it, and never more than 1,000), and each person once. A person listed twice or not in your workspace fails the whole request with `400 VALIDATION_ERROR`.

## 2. Check and fix

```bash theme={null}
GET /v1/campaigns/{id}/enrollment-batches/{batch_id}
```

The batch shows the summary counts, warnings about the whole batch (for example the mailbox's daily limit, several people sharing one email, people enrolled despite a block, or a pipeline automation that will run), and one sample email. Add `include=content` to see every person's final steps.

Fix each person who needs it:

```bash theme={null}
PATCH /v1/campaigns/{id}/enrollment-batches/{batch_id}/recipients/{recipient_id}
```

```json theme={null}
{ "blank_variables": ["personalization_line"] }
{ "email": "sam@newco.com", "add_email_to_person": true }
{ "overrides": ["company_do_not_contact"], "override_reason": "Client said Globex alumni are fine." }
{ "steps": [{ "step_id": "5b0f3c8e-...", "subject": "Quick question, Sam", "body_html": "<p>Hi Sam, ...</p>" }] }
{ "excluded": true }
```

Step text you send is the final text, so it can't contain `{{variables}}`. Every change raises the batch `version`. Changes to different people can be sent at the same time, but send all changes to one person in one request; adding people returns `409 BATCH_VERSION_CHANGED` if another change lands first, so read the batch and add them again. An email that isn't on the person's profile needs `add_email_to_person: true`; a batch can't send to an address without saving it.

To add more people to the same draft, send them with the same fields as `recipients` when you prepared it:

```bash theme={null}
POST /v1/campaigns/{id}/enrollment-batches/{batch_id}/recipients
```

```json theme={null}
{ "recipients": [{ "person_id": "8f6c1d2e-...", "variable_values": { "personalization_line": "Loved your talk." } }] }
```

A draft expires 24 hours after its last change. To throw one away, call `POST /v1/campaigns/{id}/enrollment-batches/{batch_id}/discard`.

## 3. Start

```bash theme={null}
POST /v1/campaigns/{id}/enrollment-batches/{batch_id}/start
```

```json theme={null}
{ "version": 2, "expected_recipient_count": 2 }
```

Pass the `version` and `summary.ready` you read. If either changed, you get `409` and nobody is enrolled, so you never send something you haven't seen. Start uses exactly the emails stored on the batch; if the campaign's steps changed after you prepared, start returns `409 BATCH_STALE` and you prepare a new batch.

Everyone is checked again at start. Someone who unsubscribed or was enrolled in this campaign by someone else in the meantime is listed in `rejected_at_start` with the reason, and everyone else is enrolled. `follow_up_errors` lists people who were enrolled but whose follow-up didn't finish: adding them to the job (retried automatically), saving their email as preferred, or adding them as a client contact.

Calling start again for the same batch is safe: it returns the first result with `replayed: true`, and finishes anything that was interrupted. To undo a start, call `POST /v1/campaigns/{id}/enrollments/stop` with `{ "batch_id": "..." }`.

## Variables

`GET /v1/campaigns/variables` lists the `{{variables}}` steps can use. For each person, a variable is filled from, in order:

1. The person's own `variable_values`
2. The batch's `variable_values`
3. The automatic value: the person's record (name, title, company), the sender, or the job in `job_id` (`{{search_name}}`, `{{search_client}}`, `{{application_link}}`)

`blank_variables` leaves a variable out and tidies the spacing around it. When you change one person, the latest instruction wins: leaving a variable out drops the value you gave them, and giving a value takes it off their blank list. An empty automatic value (a person with no company, a confidential job's client) needs a value or a blank.

`{{ai:name}}` variables are written by AI for each person from their profile, notes, emails and the job. While they are written the batch is `preparing`; read it again until it is `ready`. To write one yourself, pass it in `variable_values` as `"ai:name"`.

## When the first email goes out

| `start_mode` | First email |
| - | - |
| `next_window` (default) | After the first step's wait, in its next sending window, skipping weekends and holidays the campaign doesn't send on |
| `now` | Right away, even outside sending hours, on a weekend or holiday, or when the first step has a wait |
| `scheduled` | At `start_at` (a local time such as `2026-10-19T09:00:00`) in `start_timezone`, 5 minutes to 90 days ahead, even outside sending hours or on a weekend or holiday. If the first step has a wait, it counts from `start_at` and the email goes out in the next sending window |

Sending windows, weekends and holidays are read in each step's `timezone`; with `scheduled`, a first step without a timezone uses `start_timezone`. Holidays come from your workspace's holiday calendar, and `send_on_holidays: true` sends on them anyway. A mailbox's daily limit counts days in its `sending_timezone`, so a large batch spreads its first emails over several days.

Each ready person has a `first_email_estimated_at`. With `now` and `scheduled` the first email isn't moved into sending hours or off a weekend or holiday, so the batch warns with `outside_sending_window` when it would go out off schedule. With `next_window` the same warning tells you the first email has to wait, and until when:

```json theme={null}
{
  "code": "outside_sending_window",
  "message": "start_at (Sat, Dec 19 at 9:00 PM GMT) is on a weekend, and this campaign doesn't send on weekends, but a scheduled start sends the first email at start_at anyway.",
  "details": { "reason": "weekend", "sending_hours": "9:00 AM–5:00 PM GMT", "first_email_at": "2026-12-19T21:00:00.000Z" }
}
```

`reason` is `outside_hours`, `weekend` or `holiday`. Show the message before you start, the way the Stardex enroller does.

A batch's `start_mode` and `start_at` can't be changed after you prepare it. To move the first email, discard the batch and prepare a new one with the same people and values. A scheduled batch must be started before `start_at`, or start returns `409 START_TIME_PASSED`.

## Limits

With API keys, MCP and Ask Dex together, a workspace can enroll up to 500 people in 24 hours by default. Signed-in users calling the API and enrollments made in the Stardex app don't count toward it. Over that, prepare and start return `429 DAILY_ENROLLMENT_LIMIT_REACHED` with `limit`, `used`, `requested` and `resets_at`. Contact support to raise it.

## After enrolling

| Endpoint | Use |
| - | - |
| `POST /v1/campaigns/{id}/enrollments/search` | Enrollees by status group, person, job, batch, source, date, current step, reply intent, opens, clicks, or name and email, with sorting |
| `GET /v1/campaigns/{id}/enrollments/{enrollment_id}` | One enrollee with every step |
| `PATCH /v1/campaigns/{id}/enrollments/{enrollment_id}/steps/{step_id}` | Change an upcoming step for one person |
| `POST /v1/campaigns/{id}/enrollments/stop` | Stop up to 500 enrollees by `enrollment_ids`, or everyone one batch enrolled by `batch_id` |

Edits to a campaign's steps reach people enrolled afterwards. Sending window, timezone, signature and task blocking also apply to people already enrolled. A step created without a `timezone` sends in the campaign owner's mailbox timezone. If the owner has no connected mailbox the step has none, and a batch warns with `steps_without_timezone` because those emails go out in New York business hours.

A person's later emails always reply in the same thread as `Re: <first email subject>`. Changing a person's first subject, in a batch or after enrolling, renames their later subjects too.

To find who replied positively, search enrollees with `reply_intent: "positive"`; `max_opens: 0` finds people who never opened, including bounced people (`status: "bounced"` lists those).

## Campaign stats

`GET /v1/campaigns/{id}`, and `POST /v1/campaigns/search` with `include: ["stats"]`, return the numbers on the campaign card:

| Field | Meaning |
| - | - |
| `enrollment_counts` | People by status: running, awaiting a task (also counted in running or bounced), replied, meeting booked, bounced, completed, stopped |
| `engagement.responded` | People who replied or booked a meeting |
| `engagement.interested`, `neutral`, `not_interested` | How their replies were read, not counting bounced people |
| `engagement.opened`, `clicked` | People with at least one open or click, not counting bounced people |
| `engagement.reply_rate` | `responded` out of everyone enrolled except bounced people, a percent with one decimal |
| `engagement.open_rate`, `click_rate` | Out of everyone enrolled except bounced people, a whole percent; `null` when that tracking is off |

Bounced emails never reached an inbox, so they're left out of rates, as on the campaign card. To find the biggest or best campaigns, search with `sort_by: "enrollments"` or `"reply_rate"`. Rates are `null` when nobody is enrolled, or everyone enrolled bounced.

## Errors

Every error has a `code` and a `message` written for the person using your integration:

```json theme={null}
{ "success": false, "error": { "code": "RECIPIENT_COUNT_MISMATCH", "message": "You expected 37 people, but 35 are ready. Read the batch again and confirm the new count.", "details": { "ready": 35 } } }
```

| Status | Codes |
| - | - |
| 400 | `VALIDATION_ERROR`, `INVALID_TEMPLATE`, `TOO_MANY_RECIPIENTS`, `SENDER_REQUIRED`, `OVERRIDE_NOT_ALLOWED`, `OVERRIDE_REASON_REQUIRED`, `UNRESOLVED_VARIABLES`, `PIPELINE_STAGE_INVALID` |
| 403 | `INSUFFICIENT_SCOPE`, `CAMPAIGN_ACCESS_REQUIRED`, `TYPE_CHANGE_ADMIN_ONLY`, `SENDER_NOT_ALLOWED`, `SENDER_LACKS_CAMPAIGN_ACCESS`, `MAILBOX_NOT_ACCESSIBLE`, `BATCH_CREATED_BY_SOMEONE_ELSE` |
| 404 | `CAMPAIGN_NOT_FOUND`, `BATCH_NOT_FOUND`, `RECIPIENT_NOT_FOUND`, `ENROLLMENT_NOT_FOUND`, `STEP_NOT_FOUND`, `SENDER_NOT_FOUND`, `JOB_NOT_FOUND` |
| 409 | `CAMPAIGN_ARCHIVED`, `CAMPAIGN_HAS_NO_STEPS`, `MAILBOX_DISCONNECTED`, `BATCH_PREPARING`, `BATCH_NOT_EDITABLE`, `BATCH_VERSION_CHANGED`, `RECIPIENT_COUNT_MISMATCH`, `SENDER_MISMATCH`, `NOTHING_TO_ENROLL`, `BATCH_STALE`, `START_TIME_PASSED`, `BATCH_NOT_STARTED`, `STEP_NOT_EDITABLE`, `STEP_IN_USE`, `LAST_STEP` |
| 410 | `BATCH_EXPIRED` |
| 429 | `DAILY_ENROLLMENT_LIMIT_REACHED`, `RATE_LIMIT_EXCEEDED` |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.