Skip to content

Campaigns

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. Creating/editing a campaign needs EDITOR+; deleting needs ADMIN.

Configure this before you launch a marketing campaign.

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

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.

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.

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.

POST https://retidal.com/api/projects/{projectId}/email/campaigns
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"
}
namestringrequired

1–120 characters.

templateIdstringrequired

Must belong to the same project.

audienceFilterobjectrequired

See below.

providerIdstring

Channel to send from, nullable — omit to let routing pick one.

identityIdstring

A specific sending identity, nullable.

variablesobject

Values for the template’s {{variable}} placeholders, applied to every recipient in the send.

sendRatePerSecondinteger

1–100. Default 10.

scheduledAtstring

ISO 8601 timestamp for a future launch.

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

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).
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
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)

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.

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

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.

bounce/complaint entries are written automatically once you’ve set a channel’s webhook secret — no action needed on your end beyond that one-time setup.

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 first).