> ## 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.

# Campaigns, audiences, and opt-outs

> Build an audience, create and launch a campaign, track delivery, and manage opt-out keywords.

A campaign sends one template to a list of recipients. Create and run campaigns
with `messaging.campaigns`, which addresses a project by its slug. Manage
audiences, opt-out settings, and draft edits on the team client.

| Task | Permission |
| - | - |
| Read campaigns, audiences, recipients, and opt-out settings | `campaigns:read` |
| Create, change, launch, pause, resume, and stop | `campaigns:manage` |

A campaign sends from Numbers whose plan includes Campaigns. When none of the
eligible Numbers qualifies, creating or launching fails with
`402 campaigns_not_entitled`. See
[Send a campaign](/guides/campaigns/send-a-campaign).

## Build an audience

An audience is a reusable list of recipients. Audiences use a team client:

```typescript theme={null}
const audience = await platform.audiences.create({
  name: "October newsletter",
  members: [
    { phone: "+14155550100", variables: { firstName: "Ada" } },
    { phone: "+442071838750", variables: { firstName: "Alan" } },
  ],
});

console.log(audience.data.data.id, audience.data.data.invalidRows);
```

`create` accepts up to 1,000 members. Add more with
`audiences.addMembers(audienceId, { members })`. Invalid rows are reported in
`invalidRows` instead of failing the request. Page through members with
`audiences.listMembers(audienceId, { cursor })`.

## Create a campaign

```typescript theme={null}
const created = await messaging.campaigns.create(
  "<project-slug>",
  {
    name: "October newsletter",
    templateId: "<template-id>",
    recipientListId: "<audience-id>",
    sendWindow: {
      days: ["monday", "tuesday", "wednesday", "thursday", "friday"],
      hours: [{ start: "09:00", end: "18:00" }],
      timeZone: "Europe/Lisbon",
    },
  },
  { idempotencyKey: "campaign-october-newsletter" },
);

const campaignId = created.data.data.id;
```

Pass recipients inline as `recipients`, an audience as `recipientListId`, or
both. The campaign starts as a draft.

`sendWindow` limits when messages go out. Recipients outside the window stay
queued until it opens again. `timeZone` defaults to the team's time zone, or
UTC. Set `recipientTimeZone: true` to use each recipient's own time zone. Omit
`sendWindow` to send at any time.

## Add recipients

```typescript theme={null}
const added = await messaging.campaigns.addRecipients("<project-slug>", campaignId, {
  recipients: [{ phone: "+14155550101", variables: { firstName: "Grace" } }],
});

console.log(added.data.data.added, added.data.data.duplicateCount, added.data.data.invalidRows);
```

Each call adds up to 1,000 recipients before launch. The SDK sends it once and
does not retry it. If the response is lost, list the recipients before you add
them again.

## Launch, pause, resume, and stop

```typescript theme={null}
const launched = await messaging.campaigns.launch("<project-slug>", campaignId, {
  scheduledAt: Date.parse("2026-10-06T09:00:00Z"),
});
await platform.operations.wait(launched.data.data.operationId);

await messaging.campaigns.pause("<project-slug>", campaignId);
await messaging.campaigns.resume("<project-slug>", campaignId);

const stopped = await messaging.campaigns.stop("<project-slug>", campaignId);
if (stopped.data.data.operationId !== null) {
  await platform.operations.wait(stopped.data.data.operationId);
}
```

Omit `scheduledAt` to start now. Each call returns once the change is accepted;
sending continues in the background as an
[operation](/sdks/typescript/events#wait-for-an-operation). A stop that cancels
the campaign at once returns `operationId: null`, so check before you wait.

These four methods create an idempotency key when you do not pass one. If a
response is lost, read the campaign before you try again.

## Track delivery

```typescript theme={null}
const stats = await messaging.campaigns.analytics("<project-slug>", campaignId);
console.log(stats.data.data.sentCount, stats.data.data.deliveredCount);

let cursor: string | undefined;
do {
  const page = await messaging.campaigns.listRecipients("<project-slug>", campaignId, {
    status: "failed",
    cursor,
  });
  for (const recipient of page.data.data) console.log(recipient.phone, recipient.status);
  cursor = page.data.page.hasMore ? page.data.page.nextCursor ?? undefined : undefined;
} while (cursor);
```

Webhooks report each lifecycle change as `campaign.launched`, `campaign.paused`,
`campaign.resumed`, and `campaign.stopped`. See
[Campaign events](/api/webhooks#campaign-events).

Send failed recipients again with
`messaging.campaigns.requeue("<project-slug>", campaignId)`. Pass
`{ includeSkippedError: true }` to also requeue recipients skipped with an
error.

## Change a draft

Change a draft's audience or send window with the team client:

```typescript theme={null}
await platform.campaigns.update(
  campaignId,
  { sendWindow: null },
  { projectId: "<project-id>" },
);
```

`sendWindow: null` removes the window. The audience and schedule can change only
before launch. The send window can change while the campaign is a draft or
paused.

## Manage opt-out keywords

When keyword capture is on, a contact who replies to a campaign message with an
opt-out keyword joins the team's opt-out list, and campaigns skip them. An
opt-in keyword removes them again.

```typescript theme={null}
const settings = await platform.optOuts.getSettings();
console.log(settings.data.data.enabled, settings.data.data.optOutKeywords);

await platform.optOuts.updateSettings({
  enabled: true,
  optOutKeywords: ["STOP", "UNSUBSCRIBE"],
  optInKeywords: ["START"],
});
```

`updateSettings` replaces all three fields. Opt-out methods need a team key;
project credentials are refused. See
[Opt-outs](/guides/campaigns/opt-outs).


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