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

# Send a campaign

> Create a campaign with recipients, launch it, follow every recipient, and stop it, end to end through the API.

A campaign turns a list of numbers into paced, safety-checked WhatsApp messages
sent from the numbers in one project. This guide runs the whole path through the
API: build the recipient list, launch, follow the send, and stop.

Campaigns are the only bulk sending path in Polymorfa. Every send passes the
plan, throughput, opt-out, contact and warm-up checks described in
[Campaigns and number safety](/guides/campaigns/safety).

## Pick a surface

Two public paths reach the same campaign engine with the same checks, the same
errors and the same results.

| Surface | Path | Credential |
| - | - | - |
| Messaging API | `/messaging/projects/{projectSlug}/campaigns` | Project credential for that project |
| Platform API | `/platform/campaigns` | Team API key with `projectId`, or a project token for its own project |

Reading needs `campaigns:read`. Creating, launching, rescheduling, pausing,
resuming, stopping and deleting need `campaigns:manage`. The examples below use the
Messaging API; the Platform API takes the campaign id in the path. On the
Platform API a team API key names the project with `projectId` on every campaign
request, and a project token is bound to its own project and omits it.

## Create the campaign with its recipients

### Use the console

In the campaign builder, choose an audience, import a CSV, or paste phone
numbers. The import summary shows the recipients kept, duplicates, and invalid
rows. For pasted numbers, the summary stays visible after import so you can
correct the source numbers before continuing.

Select the numbers that will send the campaign. Free and Standard numbers appear
disabled in the selection, with an upgrade action, and you can save the draft
before upgrading. Each number shows its sending type. After you select one,
numbers of another sending type are disabled, because a campaign sends through
one type. Wait for **Draft saved** before launching. If saving fails,
the builder shows the reason and marks the changes as unsaved. A sending
restriction also appears in the review step; resolve it before launching.

The review step checks the selected audience again. If its recipient count
changed since the draft was saved, review the new count and confirm it before
launching. The builder checks once more when you launch; if the count changes
during review, it stops and asks you to confirm the updated audience.

Write any opt-out instruction in your message. The builder does not append a
footer or send a separate consent prompt. Review your team's
[keyword settings](/guides/campaigns/opt-outs) before sending.

Scheduling uses your browser's local time zone. Check the zone beside the
date and time and again in the review step before scheduling.

To send only during set hours, turn on **Send only during set hours** in the
schedule step. It starts with Monday to Friday, 09:00 to 18:00, in your
browser's time zone. The review step and the campaign page show the window. See
[Choose when the campaign sends](#choose-when-the-campaign-sends).

### Use the API

Send recipients inline, point at an
[audience](/guides/campaigns/audiences) with `recipientListId`, or do
both.

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns" \
  -H "Authorization: Bearer $POLYMORFA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: september-launch-1" \
  -d '{
    "name": "September launch",
    "templateId": "'"$TEMPLATE_ID"'",
    "recipients": [
      { "phone": "+14155550100", "variables": { "first_name": "Ada" } },
      { "phone": "+442071838750", "variables": { "first_name": "Alan" } }
    ],
    "senderConfig": {
      "sessionIds": ["'"$SESSION_ID"'"],
      "throughput": { "ratePerMinute": 20, "perNumberDailyCap": 500 }
    }
  }'
```

Inline `recipients` accepts at most 1,000 entries and is all or nothing: one
invalid entry refuses the request and names it, so a campaign is never created
with someone silently missing. A number repeated inside the request is kept
once. The same number and variable rules apply as for audience members.

`senderConfig.sessionIds` pins the numbers the campaign sends from. A draft can
pin numbers on any plan. When you launch, every pinned number must be connected
and on a plan that includes Campaigns. A campaign that pins none draws from the
connected, unrestricted numbers in its project whose plans include Campaigns,
using one sending type: WhatsApp numbers when any are ready, otherwise Cloud API
numbers, otherwise test numbers. To send from a specific set, pin it.

Add more recipients while the campaign is still a draft, 1,000 per request:

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns/$CAMPAIGN_ID/recipients" \
  -H "Authorization: Bearer $POLYMORFA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "recipients": [{ "phone": "+14155550101", "variables": { "first_name": "Grace" } }] }'
```

Appending reports `added`, `recipientCount`, `duplicateCount`, `invalidCount`
and up to 20 `invalidRows`, and skips a number already on the campaign. Once the
campaign has been launched, appending returns `409`.

### Edit the draft

Send only the fields you want to change. `name`, `recipientListId`,
`senderConfig`, `scheduledAt` and `sendWindow` can be updated; any other field
is refused with `400`. A non-null `scheduledAt` must be integer Unix
milliseconds from zero through the JavaScript Date maximum; an out-of-range
value returns `400`.

```bash theme={null}
curl -X PATCH "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns/$CAMPAIGN_ID" \
  -H "Authorization: Bearer $POLYMORFA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "September launch (EU)", "senderConfig": { "sessionIds": ["'"$SESSION_ID"'"] } }'
```

The Platform API accepts the same changes at
`PATCH /platform/campaigns/{campaignId}`.

To send to a different audience, update the draft with another
`recipientListId`. After the campaign launches, the audience is fixed and the
update returns `409`. Changing a draft's audience replaces its existing
recipients; add any individual recipients you still want after the change.

A draft saves any sender selection, on any plan; launch checks it. After launch,
a new `senderConfig` must pass the launch checks and can return
`402 campaigns_not_entitled` or `400 invalid_parameter`. A different
`scheduledAt` is accepted only before launch. To send a launched campaign at
another time, stop it, duplicate it, and launch the copy.

## Choose when the campaign sends

By default a campaign sends at any time of day. Set `sendWindow` on create, or
on a Messaging or Platform API update, to send only on chosen weekdays and during chosen
local hours:

```json theme={null}
{
  "sendWindow": {
    "timeZone": "America/Sao_Paulo",
    "days": ["monday", "tuesday", "wednesday", "thursday", "friday"],
    "hours": [
      { "start": "09:00", "end": "12:00" },
      { "start": "14:00", "end": "18:00" }
    ]
  }
}
```

| Field | Rule |
| - | - |
| `timeZone` | IANA time zone name, such as `Europe/Lisbon` or `UTC`. If you omit it, Polymorfa stores your team's time zone, or `UTC` when your team has none. The campaign always returns the stored zone. |
| `days` | One to seven of `monday` to `sunday`, without repeats. |
| `hours` | One to four ranges. `start` is `HH:MM` in 24-hour time and is included; `end` is `HH:MM` or `24:00` and is excluded. Ranges cannot overlap or cross midnight. For 20:00 to 02:00, use two ranges, `20:00`–`24:00` and `00:00`–`02:00`; every range applies on each day in `days`. |
| `recipientTimeZone` | `true` applies the window in each recipient's local time. Defaults to `false`. |
| `timeZoneVariable` | Recipient variable that holds the recipient's IANA time zone. Defaults to `timeZone`. |

A recipient who comes up outside the window is not failed or skipped. The
recipient stays `queued` and is sent after the window next opens, at the
campaign's normal pace. The campaign stays `running`, its counts do not change,
and no recipient event is sent for the wait. A campaign whose window is closed
for everyone sends nothing until it opens. Daylight-saving changes follow the
time zone's rules.

With `recipientTimeZone` set to `true`, each recipient's zone is:

1. the zone in the recipient variable named by `timeZoneVariable`, when it is a
   valid IANA name;
2. otherwise the zone of the phone number's country, when that whole country
   uses one UTC offset. For example, `+44` numbers use `Europe/London`.
   Countries with several offsets, such as the United States, Brazil and
   Mexico, do not resolve this way;
3. otherwise the window's `timeZone`.

Recipient zones are resolved when the campaign launches. To set a zone
yourself, add a variable with the `timeZoneVariable` name to each audience
member or inline recipient, for example `"variables": { "timeZone": "Asia/Tokyo" }`.
If more than 5,000 distinct nonempty values occur in that variable, launch
returns `400 invalid_parameter` with `error.param` set to
`sendWindow.timeZoneVariable`. Correct the audience values before launching.

An invalid window returns `400 invalid_parameter`, and `error.param` names the
field, such as `sendWindow.hours[1]`. Send `"sendWindow": null` to remove a
window. You can change or remove the window while the campaign is a draft or
paused. A running campaign returns `409 state_conflict`: pause it, update the
window, then resume. If a paused campaign still has sends in progress, the edit
returns `409 state_conflict`; retry after those sends settle. A stalled send
that has not reached the provider can be recovered on a later edit attempt.
If delivery may have started, the edit returns `409 state_conflict` while that
send is in flight. Retry after it settles. If its result is lost, the recipient
eventually becomes a failed send with an unknown outcome; the campaign does
not send it again. The new window applies to the remaining recipients.

## Launch

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns/$CAMPAIGN_ID/launch" \
  -H "Authorization: Bearer $POLYMORFA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: september-launch-start-1" \
  -d '{ "scheduledAt": 1758326400000 }'
```

Sending starts at `scheduledAt`, or right away when no start time is set. At
launch, Polymorfa copies the audience into the campaign, skips numbers on the
team's [opt-out list](/guides/campaigns/opt-outs), and sets
`recipientCount` from the recipients that exist.

For a scheduled campaign, this snapshot happens when the launch request is
accepted. Later additions, removals or changes to the source audience do not
change that campaign's recipients. Opt-outs are checked again before sending.

Launch checks the campaign's saved sender selection against the numbers' plans
at that moment, so a number that changed plan after the draft was saved is
checked on its new plan. Every pinned number must be connected and on a plan
that includes Campaigns, and all pinned numbers must use one sending type:

| Status and code | Cause |
| - | - |
| `403 bansafe_org_suspended` | The team is suspended |
| `402 campaigns_not_entitled` | A pinned number's plan does not include Campaigns, a pinned number is not in the project, or a campaign that pins none has no number on a plan that includes Campaigns in its project |
| `400 invalid_parameter` | A pinned number is disconnected, has no phone, or uses a different sending type from another selected number; `error.param` is `senderConfig.sessionIds` |
| `400 campaign_throughput_capped` | A requested throughput is above its safety ceiling; `error.param` names the field |
| `409 state_conflict` | The campaign is not a draft, is already launched and waiting for its start time, or its sending numbers or a selected number's plan or connection changed while the launch was being checked. To move a scheduled start, reschedule it. |
| `503 service_unavailable` | The numbers' plans could not be checked; retry the launch |

Create, launch, reschedule, pause, resume and stop accept an `Idempotency-Key`
header on the Messaging API; the Platform API accepts it on launch, reschedule,
pause, resume and stop. A repeated key is never a second launch. See
[Errors](/api/errors#idempotency-completed).

## Reschedule a scheduled launch

A launched campaign waits for its `scheduledAt` before it sends anything. Until
then you can move that start earlier or later, or start sending now, without
stopping and relaunching it:

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns/$CAMPAIGN_ID/reschedule" \
  -H "Authorization: Bearer $POLYMORFA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: september-launch-move-1" \
  -d '{ "scheduledAt": 1758412800000 }'
```

On the Platform API, send the same body to
`POST /platform/campaigns/{campaignId}/reschedule`, with `projectId` in the body
when you use a team API key.

`scheduledAt` is required. Set it to a Unix time in milliseconds, or to `null`
to start sending now. A time that is not in the future also starts the campaign
now. The response is the campaign with its new `scheduledAt` and the
`operationId` of its delivery run. Sending the current start time again changes
nothing.

Rescheduling changes only when the campaign starts. The recipients and messages
stay as they were when you launched it. Every send is still checked when the
campaign starts, the same as a start that was never moved. Each accepted change
is recorded as a `rescheduled` entry in the campaign's history and sends a
[`campaign.rescheduled`](/api/webhooks#campaign-events) event.

| Status and code | Cause |
| - | - |
| `400 invalid_parameter` | `scheduledAt` is missing, or is not a positive whole number or `null` |
| `403 bansafe_org_suspended` | The team is suspended. You can still stop the campaign. |
| `409 state_conflict` | The campaign has already started, is finished or being stopped, or was never launched. Launch a draft with `scheduledAt` instead. |

Once a campaign has launched, update does not change its `scheduledAt`. An update
that sends a different `scheduledAt` returns `409`; reschedule the campaign
instead.

In the console, open a scheduled campaign and choose **Reschedule**. Pick a new
date and time in your browser's time zone, or choose **Start now**.

## Follow the send

List recipients in queue order with cursor pagination. `limit` is 1 to 100 and
defaults to 25.

```bash theme={null}
curl "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns/$CAMPAIGN_ID/recipients?status=skipped&limit=100" \
  -H "Authorization: Bearer $POLYMORFA_KEY"
```

```json theme={null}
{
  "success": true,
  "data": [
    {
      "id": "0c0e4f2a-2b1d-4a67-9bb2-1f7f4a2e91a3",
      "phone": "+14155550100",
      "variables": { "first_name": "Ada" },
      "variantKey": null,
      "status": "skipped",
      "attempts": 0,
      "lastError": "opted_out",
      "externalMessageId": null,
      "queuedAt": 1758326400000,
      "sentAt": null,
      "deliveredAt": null,
      "readAt": null,
      "failedAt": 1758326402000,
      "respondedAt": null
    }
  ],
  "page": { "nextCursor": "cjE6MjU", "hasMore": true }
}
```

Follow `page.nextCursor` until `page.hasMore` is `false`. Filter with `status`:
`queued`, `sending`, `sent`, `delivered`, `read`, `failed` or `skipped`.
`lastError` gives the stable reason a recipient failed or was skipped, such as
`opted_out`. It never contains message content.

<Warning>
  `GET /platform/campaigns/{campaignId}/recipients` returns one page and a
  `page` object. If your integration expects the previous unpaginated array,
  update it to follow `page.nextCursor` until all recipients have been read.
</Warning>

Campaign analytics report the funnel and the reply timing:

```bash theme={null}
curl "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns/$CAMPAIGN_ID/analytics" \
  -H "Authorization: Bearer $POLYMORFA_KEY"
```

| Field | Meaning |
| - | - |
| `recipientCount` | Recipients on the campaign |
| `sentCount` | Messages accepted by WhatsApp |
| `deliveredCount` | Recipients whose device confirmed delivery |
| `readCount` | Recipients who opened the message; a read implies a delivery |
| `failedCount` | Sends WhatsApp refused |
| `skippedCount` | Recipients never attempted, such as an opt-out or a cold hold |
| `respondedCount` | Recipients who replied |
| `responseRate` | `respondedCount` divided by `sentCount`, and `0` before anything is sent |
| `averageResponseTimeMs` | Mean time from send to first reply, and `null` before the first reply |
| `minResponseTimeMs`, `maxResponseTimeMs` | Fastest and slowest first reply |

A reply counts once per recipient. It is attributed to the most recent recipient
that the replying number was sent to, on the same sending number, within 7 days
of that send.

The Platform API also lists the campaign's lifecycle history, most recent first:

```bash theme={null}
curl "https://api.polymorfa.com/platform/campaigns/$CAMPAIGN_ID/events?projectId=$PROJECT_ID" \
  -H "Authorization: Bearer $POLYMORFA_KEY"
```

Each entry carries `kind` — `launched`, `rescheduled`, `paused`, `resumed`,
`cancelled`, `completed` or `failed` — and the time it happened. A `rescheduled`
entry has `payload.previousScheduledAt` and `payload.scheduledAt`. A `failed` entry has
`payload.reason: "all_recipients_failed"`. The campaign fails when every recipient
settles as failed. If any recipient was sent or skipped, the
campaign completes and its counts show those outcomes.

## Pause, resume and stop

```bash theme={null}
curl -X POST "https://api.polymorfa.com/messaging/projects/$PROJECT_SLUG/campaigns/$CAMPAIGN_ID/pause" \
  -H "Authorization: Bearer $POLYMORFA_KEY"
```

Pause holds a running campaign; sends already in progress finish. Resume sends
the remaining recipients after the same checks as before the pause, including
the [send window](#choose-when-the-campaign-sends).

Stop cancels. Recipients not yet sent are not sent: each queued recipient becomes
`skipped` with `lastError` `campaign_cancelled` and is counted once in
`skippedCount`. The campaign moves to
`cancelling`, then to `cancelled` once sends already in progress finish. A
campaign with no active delivery run is `cancelled` at once and `operationId` is
`null`. Stopping is never blocked by suspension or by a lost plan entitlement,
so a restricted campaign can always be stopped.

In the console, **Stop requested** confirms that your request was accepted.
The detail page refreshes until the campaign reaches **Cancelled** and shows
the final recipient results. Sends already in progress can finish during this
transition.

A campaign that has no active delivery run cannot be paused or resumed. Stop it,
duplicate it, and launch the copy.

## Archive or delete

Archive a `completed`, `failed` or `cancelled` campaign when you want to keep
its history while marking it archived. A campaign that is still starting,
running, paused or stopping cannot be archived.

Delete removes a `draft`, `completed`, `failed`, `cancelled` or `archived` campaign. Any
other campaign, and a draft that still has a scheduled launch, is refused with
`409`: stop it first, then delete it.
If a completed or failed campaign still has a final event to deliver, delete or
archive returns `409`. Archive and delete also wait for the delivery run
to finish. Retry after the event and run have finished.

```bash theme={null}
curl -X DELETE "https://api.polymorfa.com/platform/campaigns/$CAMPAIGN_ID?projectId=$PROJECT_ID" \
  -H "Authorization: Bearer $POLYMORFA_KEY"
```

## Retry what failed

`POST .../requeue` moves failed recipients back to `queued`. Set
`includeSkippedError` to `true` to re-queue skipped recipients as well. A number
on the team's opt-out list is never claimed for sending, whatever its recipient
status is.

If delivery cannot start, the recipient stays queued for an automatic retry.
If a send may have reached WhatsApp but its result cannot be confirmed, the
recipient is not sent again automatically.


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