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.
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. |
{
"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
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"
}'
{
"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:
{
"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.
{ "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.
{
"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. |
{
"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.
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.