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.
Compliance settings
Section titled “Compliance settings”Configure this before you launch a marketing campaign.
GET /api/projects/{projectId}/email/compliance-settingsPUT /api/projects/{projectId}/email/compliance-settingsMarketing 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):
{ "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
Section titled “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
Section titled “Create a campaign”POST https://retidal.com/api/projects/{projectId}/email/campaigns{ "name": "Anniversary promo", "templateId": "tpl_x", "audienceFilter": { "hasEmail": true, "valueTier": "high" }, "variables": { "promo_code": "ANNIV20" }, "sendRatePerSecond": 10, "scheduledAt": "2026-08-01T02:00:00Z"}namestringrequired1–120 characters.
templateIdstringrequiredMust belong to the same project.
audienceFilterobjectrequiredSee below.
providerIdstringChannel to send from, nullable — omit to let routing pick one.
identityIdstringA specific sending identity, nullable.
variablesobjectValues for the template’s {{variable}} placeholders, applied to every recipient in the send.
sendRatePerSecondinteger1–100. Default 10.
scheduledAtstringISO 8601 timestamp for a future launch.
PATCH /api/projects/{projectId}/email/campaigns/{campaignId} partially updates a draft campaign (same fields, all optional).
audienceFilter
Section titled “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). |
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
Section titled “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}/launchPOST /api/projects/{projectId}/email/campaigns/{campaignId}/pausePOST /api/projects/{projectId}/email/campaigns/{campaignId}/resumeGET /api/projects/{projectId}/email/campaigns/{campaignId}/reportGET /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.
Suppressions
Section titled “Suppressions”GET /api/projects/{projectId}/email/suppressions?reason=&limit=POST /api/projects/{projectId}/email/suppressionsDELETE /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.
Observability
Section titled “Observability”GET /api/projects/{projectId}/email/overview — project-wide email totalsGET /api/projects/{projectId}/email/deliverability — daily deliverability healthGET /api/projects/{projectId}/email/messages?status=&campaignId=&limit=GET /api/projects/{projectId}/email/messages/{messageId} — full timestamp timeline for one messageGET /api/projects/{projectId}/email/approval-queuePOST /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).
Next steps
Section titled “Next steps”Build the content a campaign sends.
Send automatically instead of in a manual batch.