Email campaign endpoints

Campaigns

GET /api/email-campaigns

Email campaigns (type = "email") with statistics from the v_email_campaign_stats materialized view: total_count, valid_count, pending_count, queued_count, requested_count, sent_count, delivered_count, opened_count, clicked_count, bounced_count, suppressed_count, unsubscribed_count, blocked_count. opened_count includes unsubscribed recipients, and delivered_count excludes bounced and suppressed sends. See Campaign statistics for each definition.

POST /api/email-campaigns

Creates the campaign with type = "email". Accepts the email configuration columns — schedulerIntervalSeconds, emailsPerHour, maxEmailsPerDay, emailsPerVariationPerHour, emailWindowStart, emailWindowEnd, emailAllowWeekends, resendTopicId, templateId, segmentId, description, goal. Optional segment_id (validated before insert) is stored on the campaign and queues email-campaign/import-segment.requested after the draft exists.

GET /api/email-campaigns/{id}

Single campaign with statistics.

PATCH /api/email-campaigns/{id}

Partial update, including status — setting paused stops the next pg_cron tick from sending. completed is accepted only when the campaign is currently paused; any other current status returns 400. Completed campaigns are not broadcast. Optional segment_id (null to clear) updates the assigned segment only; it does not re-import members.

POST /api/email-campaigns/{id}/duplicate

Synchronously creates a draft copy named "<name> (copy)" with the source’s email configuration (description, goal, schedulerIntervalSeconds, emailsPerHour, maxEmailsPerDay, emailsPerVariationPerHour, emailWindowStart, emailWindowEnd, emailAllowWeekends), creates a fresh Resend topic for it (best-effort), then emits email-campaign/duplicate.requested and returns 201 with the new campaign.

Recipients are copied asynchronously by the emailCampaignDuplicate Inngest function, which pages through the source’s valid targets in chunks. A large recipient list would time out a single request, so only the campaign shell is created inline. The copy starts in draft, so it cannot begin sending before its recipients have arrived. See Email workflows.

POST /api/email-campaigns/{id}/send

Sends to a specific recipient or a small set — the manual path used for testing a variation, distinct from the scheduled broadcast.

POST /api/email-campaigns/{id}/broadcast

Internal only — Authorization: Bearer <INTERNAL_API_SECRET>. Called by pg_cron on the campaign’s schedulerIntervalSeconds.

Runs one tick of EmailCampaignService.processBroadcast and returns:

{
  "emailsEnqueued": 25,
  "throttled": false,
  "throttleReason": null,
  "variationGenerationRequested": false,
  "message": "…"
}

throttled: true means the condition will clear on its own (a cap window resetting). throttled: false with zero enqueued means an operator has to act — no active variations, or no active sending accounts. See Email campaigns.

Recipients

GET /api/email-campaigns/{id}/targets

page (default 1), page_size… — note this endpoint uses pageSize (default 25, max 500).

Parameter Filter

search

Address or name substring

has_clicked

true / false — filters on clickedAt, because there is no clicked status

category

all, none (uncategorised), or an EmailTargetCategory value

statuses

Repeatable EmailTargetStatus values, parsed by parseEmailTargetStatusQueryList

POST /api/email-campaigns/{id}/targets

Imports recipients. Contacts are upserted first, so EmailCampaignTarget.contactId and emailId are always populated. Validated by ImportEmailTargetsSchema or ImportContactIdsSchema depending on the payload shape.

PATCH /api/email-campaigns/{id}/targets/{tid}

Updates one recipient — status corrections and category overrides.

POST /api/email-campaigns/{id}/targets/from-registry

Imports from the TargetEmails registry by filter, rather than from an uploaded file.

GET /api/email-campaigns/{id}/targets/from-registry/resolve

Previews how many registry rows a filter would import, so an operator can check the blast radius first.

POST /api/email-campaigns/{id}/targets/from-segment

Queues a one-shot copy of a segment’s current active members. Body { "segment_id": "<uuid>" }. Returns 202 { queued, campaign_id, segment_id }. Also stores segmentId on the campaign (assignment only; members are still copied asynchronously). The campaign must be type = email and owned; the segment must be active and in the same subscription. See Email workflows.

GET /api/email-campaigns/{id}/targets/export

Exports the recipient list with current statuses.

Campaign variations

GET /api/email-campaigns/{id}/variations

The campaign’s own variations.

POST /api/email-campaigns/{id}/variations

Creates a variation and its Resend template. This is also the path used when copying a campaign-less template into a campaign.

PATCH /api/email-campaigns/{id}/variations/{vid}

Updates content and re-syncs the Resend template.

DELETE /api/email-campaigns/{id}/variations/{vid}

Removes a variation. Prefer setting status = archived when the variation has already been sent, so historical recipients keep a resolvable variationId.

The reusable template library is at Email content and domain endpoints.