# Recipe: launch a campaign from a CSV

> Import a lead list, create a campaign with its steps and senders, run preflight and start it over the AY Inbox API or CLI, with the launch list check explained.

Source: https://docs.ayinbox.com/api/recipes/launch-campaign-from-csv/

Launching a campaign from a CSV takes five calls. Preview the import, commit it with a column mapping, create the campaign with its steps, run preflight, then start it. The start refuses a list projected at 4% bounce or more unless you pass `acknowledge_list_risk`, and nothing sends until the start succeeds.

A **campaign** in AY Inbox is a sender pool, a schedule and an ordered list of steps sent to a set of leads (contacts enrolled in it). The campaign's `daily_limit` caps each mailbox at 50 of its emails a day by default, and a mailbox waits at least 600 seconds between sends by default. The product side is in the [campaigns guide](/guides/campaigns/); the routes are in the [contacts](/api/reference/contacts/) and [campaigns](/api/reference/campaigns/) references.

| Step                | Route                           | Key scope         |
| ------------------- | ------------------------------- | ----------------- |
| Preview the import  | `POST /contacts/import/preview` | `WRITE_CONTACTS`  |
| Commit the import   | `POST /contacts/import/commit`  | `BULK_CONTACTS`   |
| Create the campaign | `POST /campaigns`               | `WRITE_CAMPAIGNS` |
| Set senders         | `PUT /campaigns/:id/senders`    | `WRITE_CAMPAIGNS` |
| Enrol leads         | `PATCH /contacts`               | `BULK_CONTACTS`   |
| Preflight           | `POST /campaigns/:id/preflight` | `SEND_CAMPAIGNS`  |
| Today's plan        | `GET /campaigns/:id/send-plan`  | `READ_CAMPAIGNS`  |
| Start               | `POST /campaigns/:id/start`     | `SEND_CAMPAIGNS`  |

1. Preview the import [#1-preview-the-import]

Upload the file as `multipart/form-data` with a `file` field. CSV and XLSX are accepted, up to 50 MB. Nothing is written.

```bash
curl -s -X POST "https://api.ayinbox.com/v1/contacts/import/preview" \
  -H "Authorization: Bearer aybox_..." \
  -F "file=@leads.csv"
```

The answer has `columns`, `has_header`, `total_rows`, up to 20 `sample_rows` and a `suggested_mapping`: one `{index, target}` per column. A header that names an existing custom field is suggested as `custom` with that field's spelling, a column of addresses is suggested as `email` whatever its header when no header named one, and anything unmatched is `ignore`. Treat the suggestion as a default and check it.

2. Commit with a mapping [#2-commit-with-a-mapping]

Send the same file again with an `options` form field holding JSON.

```bash
curl -s -X POST "https://api.ayinbox.com/v1/contacts/import/commit" \
  -H "Authorization: Bearer aybox_..." \
  -H "Idempotency-Key: import-q3-leads" \
  -F "file=@leads.csv" \
  -F 'options={
    "mapping": [
      {"index": 0, "target": "email"},
      {"index": 1, "target": "first_name"},
      {"index": 2, "target": "company"},
      {"index": 3, "target": "custom", "custom_key": "industry"},
      {"index": 4, "target": "verification_status", "verification_provider": "zerobounce"},
      {"index": 5, "target": "consent"}
    ],
    "dedup": "update",
    "has_header": true,
    "category_ids": ["LABEL_ID"]
  }'
```

| Option                | What to know                                                                                                                                                                                  |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mapping`             | Exactly one column maps to `email`. Other targets: `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories`, `verification_status`, `consent`, or `custom` with `custom_key` |
| `verification_status` | Keeps another service's verdicts; `verification_provider` names its vocabulary, or omit it to have values recognised one by one                                                               |
| `consent`             | A date records when consent was given, `yes` records it at import time, `no` or `withdrawn` clears it                                                                                         |
| `dedup`               | `skip`, `update` or `create_duplicate` for addresses you already hold                                                                                                                         |
| `category_ids`        | Labels on every imported contact; useful to select this list in step 4                                                                                                                        |
| `campaign_ids`        | Enrol the contacts straight into existing campaigns                                                                                                                                           |

The answer counts `imported`, `updated`, `skipped` and `failed` (they sum to `total`), lists per-row `errors`, and carries a `quality` block: malformed, disposable and role addresses. `quality.flagged` is advisory; the list check that can stop a launch comes at start. Files are capped at 50,000 rows. For a large file, use [background imports](/api/reference/contacts/#background-imports) (`POST /contacts/imports`, then `/analyze` and `/start`), which take the same mapping and survive a dropped connection. Keeping your own list and verification is covered in [bring your own leads and mailboxes](/learn/bring-your-own-leads-and-mailboxes/).

3. Create the campaign with steps [#3-create-the-campaign-with-steps]

```bash
curl -s -X POST "https://api.ayinbox.com/v1/campaigns" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: campaign-q3" \
  -d '{
    "name": "Q3 outbound",
    "stop_on_reply": true,
    "unsubscribe_header": true,
    "daily_limit": 30,
    "timezone": "America/New_York",
    "steps": [
      {"name": "Intro", "subject": "Quick question, {{.FirstName}}", "body_plain": "Hi {{.FirstName}}, ..."},
      {"name": "Bump", "body_plain": "Just bumping this, {{.FirstName}}.", "wait_after": 3},
      {"name": "Last", "body_plain": "Closing the loop.", "wait_after": 4}
    ]
  }'
```

* **Steps are connected in order.** Each step waits its own `wait_after` days after the contact's previous step. The first step's `wait_after` defaults to `0`, follow-ups to `3`. A step with no flow connection after it ends the sequence.
* **Threading follows the subject.** A step with no subject, or the same subject as the conversation, is sent as a reply in the same thread. A step with a subject of its own opens a new conversation. Set `thread_reply` to override.
* **`daily_limit` is per mailbox**, from 3 to 5,000. Keep it at or below the default 50 unless a mailbox has the history to justify more.
* **Merge fields** use `{{.FirstName}}`, `{{.Company}}` and custom fields such as `{{.industry}}`. See [personalization](/guides/expressions/).

The campaign is created as a draft. Nothing sends yet.

4. Add senders and leads [#4-add-senders-and-leads]

**Senders.** With the default `sender_strategy: "tags"`, the pool comes from `email_tag_ids`, and with no tag and no explicit sender it falls back to every active mailbox in the workspace. To name mailboxes, set `sender_strategy: "explicit"` on the campaign and replace the pool:

```bash
curl -s -X PUT "https://api.ayinbox.com/v1/campaigns/CAMPAIGN_ID/senders" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -d '{"senders": [{"email_account_id": "MAILBOX_1", "weight": 1}, {"email_account_id": "MAILBOX_2", "weight": 1}]}'
```

An explicit pool never widens to the whole workspace. If it empties, the campaign parks at `paused_no_accounts`.

**Leads.** Select the imported list by its label and enrol it:

```json
{ "all": true, "filters": { "category_ids": ["LABEL_ID"] }, "add_campaigns": ["CAMPAIGN_ID"] }
```

Send that to `PATCH /contacts`. If you created the campaign before the import, `campaign_ids` in the commit options does the same in one step.

5. Preflight and plan [#5-preflight-and-plan]

`POST /campaigns/:id/preflight` runs the campaign's checks and sends no mail. It returns `passed`, a `score` and a list of `checks`, each with `key`, `passed`, `severity`, `message` and `remediation`: tracking domain, unsubscribe header, daily limit, schedule window, A/B configuration, content score and `list_quality`, the same bounce projection the start applies. Fix what it reports before starting.

`GET /campaigns/:id/send-plan` answers what the campaign sends today and every limit that decided it: `projected_today`, a `bottleneck`, the `limits` that removed volume (warmup graduation, the campaign limit, other campaigns on the same mailboxes, sending windows) and each mailbox's `cap_today`. On a draft it shows the `not_running` limit, so read it again after the start for real numbers, and before raising any cap.

6. Start [#6-start]

```bash
curl -s -X POST "https://api.ayinbox.com/v1/campaigns/CAMPAIGN_ID/start" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: start-q3" \
  -d '{}'
```

Starting puts real mail on the wire, so it needs `SEND_CAMPAIGNS` (for a member, **Send campaigns**). It answers `{"status": "started", "waiting_for_leads": false}`.

The start first measures the list. On a list of at least 50 deliverable leads, counting only addresses verification actually rejected:

| Projected bounce rate                        | What happens                                      |
| -------------------------------------------- | ------------------------------------------------- |
| 4% or more                                   | Refused with `list_bounce_risk` and the number    |
| 2% to under 4%                               | Starts; preflight's `list_quality` check flags it |
| Under 2%, or fewer than 50 deliverable leads | Starts                                            |

For a list you verified with another service, repeat the request with `{"acknowledge_list_risk": true}`. Other refusals: `leads_undeliverable` when every remaining lead was refused by verification (the campaign parks at `paused_undeliverable`), and `empty_step_body` when an email step has no body. An `Idempotency-Key` makes a retried start replay the first answer instead of running twice.

The same in the CLI [#the-same-in-the-cli]

The import routes take a multipart file, which the `ayinbox` CLI does not build, so upload the CSV with curl as above, or create contacts from JSON with `ayinbox contact create --input @contacts.json`. The rest maps to commands:

```bash
ayinbox campaign create --input @campaign.json --idempotency-key campaign-q3
ayinbox campaign set-senders CAMPAIGN_ID --input @senders.json
ayinbox api /contacts -X PATCH --input @enrol.json
ayinbox campaign preflight CAMPAIGN_ID
ayinbox campaign plan CAMPAIGN_ID
ayinbox campaign start CAMPAIGN_ID
```

`campaign start` prompts before it sends, and with no terminal it refuses. Pass `--yes` only when a person asked for that specific launch, never globally to silence prompts. Every command and flag is on the [CLI](/api/cli/) page.

## Frequently asked questions

### Can I create and start a cold email campaign entirely over the API?

Yes. Import contacts with POST /contacts/import/commit, create the campaign and its steps with POST /campaigns, run POST /campaigns/:id/preflight, then POST /campaigns/:id/start. Starting needs the SEND_CAMPAIGNS scope.

### Why was my campaign start refused with list_bounce_risk?

On a list of at least 50 deliverable leads, AY Inbox refuses a launch whose projected bounce rate is 4% or more, counting only addresses verification rejected. Clean the list, or repeat the start with acknowledge_list_risk: true for a list you verified elsewhere.

### How big can a CSV import be?

Up to 50 MB and 50,000 rows per file. Small files can be committed inside the request; large ones go through background imports, which run on the server in chunks.

### Which import field keeps a verification verdict from another service?

verification_status. Map the other service's result column to it in the import mapping; the verdicts are stored as imported, and the background check leaves those contacts alone.

### Can the ayinbox CLI upload the CSV file?

The import routes take a multipart file upload, which the CLI does not build, so upload the file with curl or your HTTP client. The CLI covers the rest: creating contacts from JSON, the campaign, preflight and start.
