# Campaigns & sending

One broadcast to many recipients. The scope split runs straight through this
page: `campaigns:preview` renders and resolves recipients, `campaigns:send`
puts mail on the wire, and no key holds the second without a human granting it.

For the flow rather than the contract — which rendering path to pick, how to
read a preview — see [Sending a campaign](/docs/campaigns).

## `GET /api/campaigns`

Campaigns newest-first with delivery and engagement stats. This is how to
answer "how did the last campaign do?".

**Scope:** `campaigns:read`

| Parameter | In | Type | Notes |
| --- | --- | --- | --- |
| `projectId` | query | string | **Required.** |
| `status` | query | enum | `draft`, `scheduled`, `sending`, `sent`, `failed`. |
| `limit` | query | integer | Default 50, capped at 200. |

```json
{
  "campaigns": [
    {
      "id": "cmp_…",
      "subject": "What shipped in July",
      "template": "newsletter",
      "status": "sent",
      "recipientMode": "list:newsletter",
      "recipientCount": 4102,
      "scheduledAt": null,
      "sentAt": "2026-07-31T09:00:00.000Z",
      "createdAt": "2026-07-30T18:22:00.000Z",
      "stats": {
        "sends": 4102,
        "delivered": 4061,
        "opens": 1544,
        "clicks": 233,
        "bounces": 31,
        "complaints": 2
      }
    }
  ],
  "total": 18,
  "returned": 18
}
```

`stats` is computed from the send log, so it reflects what actually happened
rather than what was intended. An unknown `status` value returns `400` listing
the valid ones.

## `POST /api/send`

One endpoint, two very different acts, chosen by `mode`.

**Scope:** `campaigns:preview` when `mode` is `"preview"`, otherwise
`campaigns:send`.

That is the whole guarantee: an agent key carries the first and not the
second, so it can render a campaign and resolve exactly who would receive it,
and cannot deliver it. `mode: "preview"` is checked **before** the `test:`
branch, so a preview never puts mail on the wire even with a `test:`
recipient.

### Body

| Field | Type | Notes |
| --- | --- | --- |
| `projectId` | string | **Required.** |
| `template` | string | Built-in React template: `announcement`, `competition-launch`, `competition-results`, `feature-announcement`, `newsletter`, `product-grid`, `welcome`. |
| `subject` | string | English subject. |
| `content` | string | English body copy. Rendered **literally** — `{{tokens}}` do not resolve here. |
| `subjectPt` / `bodyPt` | string | Portuguese variants. |
| `htmlEn` / `textEn` / `htmlPt` / `textPt` | string | Pre-rendered stored-template output, where tokens *do* resolve per recipient. |
| `recipientMode` | string | `all`, `list:<slug>`, `tag:<tag>` or `test:<email>`. Defaults to `all`. |
| `data` | object | Payload for data-driven templates (`product-grid` needs `data.products`). |
| `scheduledAt` | string | ISO 8601. Creates a scheduled campaign instead of sending now. |
| `mode` | string | `"preview"` to render without sending. |

### Preview

```bash
curl -X POST https://pharosbase.com/api/send \
  -H "Authorization: Bearer $PHAROS_AGENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "acme-app",
    "template": "newsletter",
    "subject": "What shipped in July",
    "content": "Here is what changed this month.",
    "recipientMode": "list:newsletter",
    "mode": "preview"
  }'
```

```json
{
  "mode": "preview",
  "recipientCount": 4102,
  "sampleRecipient": { "email": "reader@example.com", "language": "en" },
  "subject": "What shipped in July",
  "html": "<html>…</html>",
  "text": "…"
}
```

Nothing is persisted: no campaign row, no queued batches, no send events. The
render uses a **real** recipient so the preview shows the locale someone would
actually receive rather than assuming English.

When nothing matches, you get the count and a warning instead of a render:

```json
{
  "mode": "preview",
  "recipientCount": 0,
  "warning": "No recipients match this recipientMode"
}
```

### Test send

`recipientMode: "test:someone@example.com"` delivers one real email and creates
no campaign.

```json
{ "success": true, "status": "test_sent", "recipientCount": 1, "mock": false }
```

`mock: true` means no sending credential is configured and nothing left the
building — useful locally, misleading if you read it as a delivery. If the test
address is already suppressed you get `status: "suppressed_cleaned"` instead,
and the contact is unsubscribed rather than mailed.

### Send

Omit `mode` and use a non-`test:` `recipientMode`. A campaign row is created
and recipients are resolved and queued in batches.

```json
{
  "success": true,
  "campaignId": "cmp_…",
  "status": "queued",
  "recipientCount": 4102,
  "batchesQueued": 293
}
```

| `status` | Meaning |
| --- | --- |
| `queued` | Batches are on the queue; delivery is in flight. |
| `scheduled` | `scheduledAt` was set; the campaign fires then. |
| `sent` | Delivered inline (no queue configured) or nothing to send. |

A campaign with no matching recipients completes immediately with
`recipientCount: 0` and a `warning` rather than erroring.

**A `2xx` here means accepted, not delivered.** Watch `GET /api/sends` for
per-recipient outcomes.

## `GET /api/sends`

Per-recipient delivery records across campaigns, automations and transactional
events — the answer to "did this person actually get the email?".

**Scopes:** `campaigns:read` **and** `contacts:read`. This response crosses
contact identities with campaign activity, so it demands both grants; a key
holding one gets `403`.

| Parameter | In | Type | Notes |
| --- | --- | --- | --- |
| `projectId` | query | string | **Required.** |
| `email` | query | string | Case-insensitive substring match on the recipient. |
| `campaignId` | query | string | From `GET /api/campaigns`. |
| `status` | query | enum | `queued`, `sent`, `delivered`, `bounced`, `complained`. |
| `source` | query | enum | `campaign`, `automation`, `transactional`. |
| `limit` | query | integer | Default 50, capped at 200. |

```json
{
  "sends": [
    {
      "id": "snd_…",
      "source": "campaign",
      "campaignId": "cmp_…",
      "templateId": null,
      "subject": "What shipped in July",
      "contactEmail": "reader@example.com",
      "status": "delivered",
      "deliveredAt": "2026-07-31T09:00:04.000Z",
      "openedAt": "2026-07-31T09:14:22.000Z",
      "clickedAt": null,
      "bouncedAt": null,
      "bounceType": null,
      "complainedAt": null,
      "createdAt": "2026-07-31T09:00:00.000Z"
    }
  ],
  "matched": 4102,
  "returned": 50
}
```

Status advances as the sending provider reports back: `queued → sent →
delivered`, ending at `bounced` or `complained`. A row keeps the subject and
template captured at send time, so history stays readable even if the campaign
is edited or deleted afterwards.

Rows with `bounceType: "Permanent"` and any `complained` row are suppressions.
Both unsubscribe the contact across the whole project, and neither can be
reversed by a contact sync — see
[Unsubscribes & suppression](/docs/compliance).

Delivery state only advances if the provider's webhook is configured. If every
send sits at `sent`, that is the thing to check — see
[Sending & deliverability](/docs/sending-providers#delivery-tracking).
