Pharos
API reference.md

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.