# Send an email campaign
URL: https://samva.dev/docs/channels/email/send-a-campaign



Use a campaign for a named broadcast whose recipients come from contact IDs or tags. Creating a
campaign sends nothing. A run snapshots the definition and starts immediately or at a future time.

## Define the campaign [#define-the-campaign]

Send campaigns as email. A run supports at most 25,000 resolved recipients. Choose exactly one
content source:

* Inline email requires `subject` and at least one of `html` or `text`.
* Template email references exactly one published template by `templateSlug` or `templateId`, with
  optional `templateData`. Do not mix it with inline `subject`, `html`, or `text`. Add `locale` and
  `timeZone` to format the template's dates, numbers, and money for the audience; see
  [Format dates and money for the recipient](/docs/channels/email/send-an-email#format-dates-and-money-for-the-recipient).

The audience must use `includeTags` or `contactIds` as its source. `excludeTags` removes matches.
Contacts resolve when the run starts. After the first run is created, the campaign definition is
frozen and updates return a conflict.

## Sign off with the brand footer [#sign-off-with-the-brand-footer]

Every campaign email ends with a brand footer: your company name, postal address, and an
[unsubscribe link](#choose-the-unsubscribe-group). Samva appends the footer of the
sending domain's brand unless the template renders `<BrandFooter />` itself. Add a postal address to
that brand before scheduling a run, and keep the footer on unless the template renders
`<BrandFooter />`; [Brands](/docs/sml/brands#campaigns) lists the errors each rule returns.

## Choose the unsubscribe group [#choose-the-unsubscribe-group]

A run resolves its unsubscribe group when you schedule it, from the first of these that sets one:
the campaign's `policy.unsubscribeGroupId`, the template's group, the sending domain's default, and
the organization's default. Every organization has exactly one default group, named Marketing until
you rename it. Every email in the run shows the unsubscribe link in its footer and carries one-click
`List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers for that group,
and contacts who already left the group are skipped with reason `unsubscribed`. The run's
`policySnapshot.unsubscribeGroupId` shows the group it resolved. Scheduling fails if the group no
longer exists, and a delivery that resolved a group without its unsubscribe URL fails instead of
sending without an opt-out.

A one-click unsubscribe removes the contact from the group in the link only. The hosted preference
page lets the contact toggle each group or unsubscribe from all of them.

Change the organization's default with
[Set the default unsubscribe group](/docs/api-reference/unsubscribe/setDefault); the previous default
stays a regular group. Deleting the default group, or a group that a scheduled, resolving,
dispatching, or paused run uses, returns a `409` `ConflictError`. Transactional sends carry a group
only when the request or template names one.

## Use the Promise SDK [#use-the-promise-sdk]

```typescript title="send-campaign.ts"
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const campaign = await samva.campaigns.create({
  name: "August newsletter",
  channel: "email",
  content: {
    channel: "email",
    email: { subject: "What's new", html: "<h1>August updates</h1>" },
  },
  audience: { includeTags: ["newsletter"], excludeTags: ["bounced"] },
});

const run = await samva.campaigns.scheduleRun({
  id: campaign.id,
  scheduledFor: "2026-08-01T09:00:00Z",
  idempotencyKey: "august-newsletter-v1",
});

console.log("Campaign run:", run.id);
```

## Use the Effect SDK [#use-the-effect-sdk]

```typescript title="send-campaign-effect.ts"
import { Effect } from "effect";
import * as Campaigns from "samva/effect/campaigns";
import * as Client from "samva/effect/client";

const program = Effect.gen(function* () {
  const campaign = yield* Campaigns.create({
    name: "August newsletter",
    channel: "email",
    content: {
      channel: "email",
      email: { subject: "What's new", html: "<h1>August updates</h1>" },
    },
    audience: { includeTags: ["newsletter"] },
  });

  return yield* Campaigns.scheduleRun({
    id: campaign.id,
    scheduledFor: new Date("2026-08-01T09:00:00Z"),
    idempotencyKey: "august-newsletter-v1",
  });
}).pipe(Effect.provide(Client.layerFetch({ apiKey: process.env.SAMVA_API_KEY! })));

await Effect.runPromise(program);
```

## Use the REST API [#use-the-rest-api]

```bash
curl -X POST https://api.samva.dev/v1/campaigns \
  -H "X-API-Key: $SAMVA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "August newsletter",
    "channel": "email",
    "content": {
      "channel": "email",
      "email": { "subject": "What is new", "html": "<h1>August updates</h1>" }
    },
    "audience": { "includeTags": ["newsletter"] }
  }'

curl -X POST https://api.samva.dev/v1/campaigns/<campaign-id>/runs \
  -H "X-API-Key: $SAMVA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduledFor": "2026-08-01T09:00:00Z",
    "idempotencyKey": "august-newsletter-v1"
  }'
```

Omit `scheduledFor` to start immediately. When present, it must be an absolute ISO-8601 instant
with `Z` or a numeric offset. Campaign runs do not accept timezone metadata. See the
[generated campaigns reference](/docs/api-reference/campaigns/create) for every operation.

## Use the CLI [#use-the-cli]

```bash
samva campaigns create --name "August newsletter" \
  --subject "What's new" --html "<h1>August updates</h1>" \
  --include-tags newsletter
samva campaigns runs create <campaign-id> --at 2026-08-01T09:00:00Z \
  --idempotency-key august-newsletter-v1
samva campaigns runs get <campaign-id> <run-id>
samva campaigns runs recipients <campaign-id> <run-id> --status failed
```

Use `samva campaigns create --dry-run` to validate the definition without creating it. Use
`samva campaigns runs pause`, `resume`, or `cancel` with the campaign ID and run ID to control a run.

## Use MCP [#use-mcp]

Call `campaigns_create`, then `campaigns_schedule_run`. Track progress with
`campaigns_get_run` and inspect outcomes with `campaigns_list_recipients`. The
`campaigns_control_run` tool accepts `pause`, `resume`, or `cancel`.

## Track progress and completion [#track-progress-and-completion]

A run counts its recipients while it sends. `dispatchedCount`, `failedCount`, and `skippedCount`
rise as recipients settle, and until every contact has resolved `totalRecipients` is at least the
audience the run started with. While a run is resolving or sending, `estimatedCompletionAt` says
when it should finish, from the recipients still waiting and the sending capacity available when
the run started. It is `null` for a scheduled, paused, or finished run.

A larger audience takes longer, and the estimate moves as the run progresses. The campaign's run
page in the dashboard shows the same progress and estimate.

## Idempotency, controls, errors, and usage [#idempotency-controls-errors-and-usage]

Pass an `idempotencyKey` when scheduling a run. It is scoped to the organization, and replaying the
same key returns the original run. Pausing stops new sends, and resuming picks up the recipients
that have not been sent yet. Cancelling a future scheduled run is
terminal. Cancellation after sending begins is best effort, so emails already on their way may
still be delivered; recipients still waiting to send fail with `CAMPAIGN_CANCELLED`. Each recipient
must start sending within the campaign
[send window](/docs/channels/email/send-an-email#accepted-queued-and-expired-email), which runs from
the run's start and includes time spent paused.

If email review is no longer clear just before a recipient's email goes out, the run pauses with
an `onboarding_review:<reason>` hold in `holdReason`, and that recipient is not sent. The run stays
paused after approval until you resume it with the dashboard, CLI, API, or `campaigns_control_run`.
Resuming checks review again for each remaining recipient; Samva never resumes the run on its own.

Each recipient follows the normal email send and usage path when dispatched. Run totals distinguish
dispatched, failed, and skipped recipients; recipient records carry the outcome. Invalid state
transitions and edits after the first run return conflicts. See the
[error reference](/docs/developers/error-reference) for shared API error handling.
