---
title: Campaigns
description: Bulk-send a template to a filtered audience, plus the compliance settings and suppression list marketing mail depends on.
---

A campaign sends one template to an audience you define with a filter — anything from "everyone with an
email on file" down to a single person. There's no separate "send one email" endpoint; a one-off send is a
campaign whose `audienceFilter` matches exactly one user.

Authentication for every endpoint on this page is the console session cookie — see
[Providers § Authentication](/docs/email/providers#authentication). Creating/editing a campaign needs
EDITOR+; deleting needs ADMIN.

## Compliance settings

Configure this before you launch a marketing campaign.

```
GET /api/projects/{projectId}/email/compliance-settings
PUT /api/projects/{projectId}/email/compliance-settings
```

<Danger title="Marketing sends fail closed without this">
  A `marketing`-category campaign launch **aborts entirely** if compliance settings aren't configured. There
  is no partial-send fallback. Configure this before you build your first marketing campaign, not after a
  launch fails.
</Danger>

`PUT` is a whole-object replace — `companyName`, `physicalAddress`, and `emailLocaleStrict` are all required
in the body (the first two are nullable strings, `emailLocaleStrict` is a required boolean):

```json
{
  "companyName": "Your Company, Inc.",
  "physicalAddress": "123 Main St, Suite 100, San Francisco, CA 94105",
  "contact": "support@yourdomain.com",
  "emailLocaleStrict": false
}
```

Once configured, Retidal automatically injects a `List-Unsubscribe` header (RFC 8058) and a compliance
footer into every marketing send. `transactional` category sends are exempt from this whole gate.

### Consent history

```
GET  /api/projects/{projectId}/email/consent?email=...
POST /api/projects/{projectId}/email/consent   { email, category, status: "granted"|"revoked", source, proof? }
```

Both `GET /api/projects/{projectId}/email/consent` and `POST /api/projects/{projectId}/email/consent` require `TRACKLY_PII_HMAC_KEY` to be configured on the
worker — without it, they return `500`.

## Create a campaign

<CodeGroup>
```text title="Endpoint"
POST https://retidal.com/api/projects/{projectId}/email/campaigns
```
```json title="Request body"
{
  "name": "Anniversary promo",
  "templateId": "tpl_x",
  "audienceFilter": { "hasEmail": true, "valueTier": "high" },
  "variables": { "promo_code": "ANNIV20" },
  "sendRatePerSecond": 10,
  "scheduledAt": "2026-08-01T02:00:00Z"
}
```
</CodeGroup>

<ParamField name="name" type="string" required>
  1–120 characters.
</ParamField>
<ParamField name="templateId" type="string" required>
  Must belong to the same project.
</ParamField>
<ParamField name="audienceFilter" type="object" required>
  See below.
</ParamField>
<ParamField name="providerId" type="string">
  Channel to send from, nullable — omit to let routing pick one.
</ParamField>
<ParamField name="identityId" type="string">
  A specific sending identity, nullable.
</ParamField>
<ParamField name="variables" type="object">
  Values for the template's `{{variable}}` placeholders, applied to every recipient in the send.
</ParamField>
<ParamField name="sendRatePerSecond" type="integer">
  1–100. Default 10.
</ParamField>
<ParamField name="scheduledAt" type="string">
  ISO 8601 timestamp for a future launch.
</ParamField>

`PATCH /api/projects/{projectId}/email/campaigns/{campaignId}` partially updates a **draft** campaign (same fields, all optional).

### audienceFilter

Every field is optional and they compose (AND'd together); this same shape is reused as a trigger's
`match.userFilter`:

| Field | Type | Notes |
| --- | --- | --- |
| `locale` | string or string[] | |
| `hasEmail`, `hasPhone` | boolean | |
| `userIdLike` | string | |
| `firstSeenFrom` / `firstSeenTo` | ISO datetime | |
| `lastSeenFrom` / `lastSeenTo` | ISO datetime | |
| `savedAudienceId` | string | Reference to a saved audience (see [Console queries](/docs/console/queries)). |
| `queueKey` | enum | `new_paid_activation`, `key_created_low_usage`, `high_balance_inactive`, `high_value`, `low_balance_renewal`, `churn_risk_paid` |
| `paidStatus`, `valueTier`, `lifecycleStage`, `recommendedAction` | string or string[] | |
| `minLtv` / `maxLtv`, `minBalance` / `maxBalance`, `minUsage30d` / `maxUsage30d` | number | |
| `lastPurchaseFrom` / `lastPurchaseTo` | ISO datetime | |

## Preview, launch, and control a send

```
POST /api/projects/{projectId}/email/campaigns/{campaignId}/preview   { "audienceFilter": {...} }
  → { total, eligible, suppressionPreview, willReceive, etaSeconds }

POST /api/projects/{projectId}/email/campaigns/{campaignId}/launch
POST /api/projects/{projectId}/email/campaigns/{campaignId}/pause
POST /api/projects/{projectId}/email/campaigns/{campaignId}/resume
GET  /api/projects/{projectId}/email/campaigns/{campaignId}/report
GET  /api/projects/{projectId}/email/campaigns/{campaignId}?days=30   (effort/click/ROI detail)
```

<Note title="Preview is an estimate, not a guarantee">
  `preview` computes `willReceive` from the current audience and suppression list at the time you call it.
  Actual dispatch re-checks the suppression list **per recipient** at send time — someone who unsubscribes
  between preview and launch is skipped even though preview counted them.
</Note>

## Suppressions

```
GET    /api/projects/{projectId}/email/suppressions?reason=&limit=
POST   /api/projects/{projectId}/email/suppressions
DELETE /api/projects/{projectId}/email/suppressions/{id}
POST   /api/projects/{projectId}/email/suppressions/import     (CSV/bulk — { emails: [...] } and/or { text: "one per line" })
```

`reason` accepts six values: `bounce`, `complaint`, `unsubscribe`, `manual`, `imported`, `inbound_unsubscribe`.
`scope` is `project` (default) or `global`.

For the three reasons Retidal's own pipeline writes automatically, blocking works like this:

| reason | Blocks marketing | Blocks transactional |
| --- | --- | --- |
| `unsubscribe` | Yes | **No — transactional mail (receipts, password resets) still sends.** This is intentional, not a bug. |
| `bounce` | Yes | Yes |
| `complaint` | Yes | Yes |

<Note>
  The other three reason values (`manual`, `imported`, `inbound_unsubscribe`) are valid, schema-level
  suppression reasons — used for entries you add yourself via `POST`/`import`, or that inbound-email
  processing writes — but their exact blocking-tier behavior isn't separately documented anywhere the docs
  team could verify at time of writing. Treat a `manual`/`imported` entry as at least as strict as
  `unsubscribe` until you've confirmed otherwise for your use case.
</Note>

`bounce`/`complaint` entries are written automatically once you've set a channel's
[webhook secret](/docs/email/providers#esp-bounceoutcomplaint-webhooks) — no action needed on your end beyond
that one-time setup.

## Observability

```
GET /api/projects/{projectId}/email/overview            — project-wide email totals
GET /api/projects/{projectId}/email/deliverability       — daily deliverability health
GET /api/projects/{projectId}/email/messages?status=&campaignId=&limit=
GET /api/projects/{projectId}/email/messages/{messageId} — full timestamp timeline for one message
GET /api/projects/{projectId}/email/approval-queue
POST /api/projects/{projectId}/email/approval-queue/{id}/review   { action: "approve" | "reject" }   (ADMIN)
```

`email_messages.attachmentsJson` on a single message's detail carries an attachment-by-attachment `status`:
`attached` (resend, inline), `linked` (aliyun degrades to a signed download link in the body — aliyun has no
native attachment support), `attachment_missing` (asset gone, or the signing key isn't configured),
`attachment_fetch_failed` (dynamic-attachment fetch failed — check the
[allowlist](/docs/email/templates#dynamic-attachment-domain-allowlist) first).

## Next steps

<CardGroup cols={2}>
  <Card title="Templates" href="/docs/email/templates">
    Build the content a campaign sends.
  </Card>
  <Card title="Event triggers" href="/docs/email/triggers">
    Send automatically instead of in a manual batch.
  </Card>
</CardGroup>
