Campaigns
Broadcast email campaigns to contact groups.
Campaigns send one email to many recipients (newsletters, product announcements). The workflow: create a draft, configure content and audience, optionally send a test, then queue for sending.
List campaignsGET/v1/campaigns
Permission: read. Ordered by created_at, newest first.
| Query param | Description |
|---|---|
| status | Filter by status: draft, scheduled, sending, sent, paused or cancelled. Other values return 400. |
| limit | Default 20, max 100. |
| offset | Default 0. |
Get a campaignGET/v1/campaigns/{id}
Permission: read. Returns { campaign } with all campaign fields (including content_html, content_json and content_text), plus contact_group { id, name, color, total_contacts } and template { id, name, subject, email_format } when linked. Returns 404 if the campaign does not exist in your account.
Create a campaignPOST/v1/campaigns
Permission: write. Creates a campaign in draft status and returns 201 with { campaign }.
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | No | Campaign name (non-empty string). Default: "Untitled Broadcast". |
| clone_from | string | No | ID of an existing campaign to copy subject, preview_text, sender (from_email, from_name, reply_to), content, audience, send_type and template_id from. Unknown IDs return 404. |
| template_id | string | No | Email template ID (e.g. etpl_xxxxxxxx). Links the campaign to a template instead of providing content_html directly. Ignored when clone_from is set (the source campaign template is used). |
Configure the campaignPATCH/v1/campaigns/{id}
Permission: write. Set all the fields needed before sending. Only the fields below are accepted; other keys are ignored, and a body with none of them returns 400. Returns { campaign }.
| Field | Type | Description |
|---|---|---|
| name | string | Campaign name. |
| subject | string | Email subject line. |
| preview_text | string | Email preview text (shown in inbox previews). |
| from_email | string | Sender email. Must be a valid address (400 otherwise); its domain must be verified for outbound email at send time. |
| from_name | string | Sender display name. |
| reply_to | string | Reply-to address. Must be a valid address, empty string or null. |
| template_id | string | Email template ID (etpl_...). If set, the template HTML is used at send time instead of content_html. Must exist in your account (404 otherwise). Set to null to unlink. |
| content_html | string | HTML content of the email. Ignored at send time if template_id is set. |
| content_text | string | Plain text fallback. |
| content_json | object | Editor JSON (used by the visual editor). |
| contact_group_id | string | Single contact group ID (legacy). |
| contact_group_ids | string[] | Array of contact group IDs for the audience (400 if not an array). Takes precedence over contact_group_id. |
| total_recipients | number | Override total recipients count (auto-calculated at send time if 0). |
| send_type | string | immediate (default) or scheduled. |
| scheduled_at | string | ISO 8601 date for scheduled sends. Set it to a future time: it is not validated, and a past time is sent on the next cron run. |
| status | string | Allowed: draft, scheduled, paused, cancelled (400 otherwise). Cannot set sending or sent manually. |
| email_format | string | broadcast (default) or email. Controls how the HTML is wrapped. When a template is linked, the template email_format is used instead. |
Campaign workflow
Creating a campaign always requires at least 2 API calls: POST to create the draft, then PATCH to configure content, sender and audience. Once configured, you call /send to queue it. Here are the 3 scenarios:
Example 1: Immediate send with HTML
You provide the HTML directly via content_html. All recipients receive the same content.
Example 2: Immediate send with template (personalized)
Link a template via template_id. The template HTML can contain {{variables}} that are resolved for each contact (e.g. "Hello {{first_name}}" becomes "Hello Jane" for Jane Doe).
Example 3: Scheduled send
Set send_type to "scheduled" and scheduled_at to an ISO 8601 date in the future. The campaign is queued and the cron job sends it at the specified time. Works with both content_html and template_id. If send_type is "scheduled" but scheduled_at is empty, /send sends immediately.
Templates and variables
When a campaign uses template_id, the API fetches the latest HTML from the template at send time. If you update the template later, future sends automatically use the new version.
System variables
These variables are resolved automatically for each recipient from their contact record. Use {{key}} with no spaces inside the braces. Variables are replaced in the template HTML only, not in the campaign subject.
| Variable | Resolves to | Example |
|---|---|---|
| {{name}} | Full name of the contact. | Jane Doe |
| {{first_name}} | First word of the full name. | Jane |
| {{email}} | Email address of the contact. | jane@example.com |
| {{phone}} | Reserved. Currently always resolves to an empty string in campaign sends. | (empty) |
| {{unsubscribe_url}} | Unique unsubscribe link (auto-generated). | https://app.tickos.dev/unsubscribe?token=... |
Custom variables
Templates can define custom variables with default values (e.g. {{company_name}} with default "Acme"). These are configured in the template itself from the dashboard. Campaigns do not accept per-recipient custom values, so every recipient gets the template default. Variables with no value are left as-is.
Send a test emailPOST/v1/campaigns/{id}/test-send
Permission: write. Sends the campaign content to a single test email address directly via SES (bypasses the queue). The campaign needs subject, content (content_html or template_id) and from_email (400 otherwise). The subject is prefixed with [Test]. If the campaign uses a template, variables are resolved with test data (name: "Test User", email: the test address) and template defaults; no custom values are applied. {{unsubscribe_url}} is replaced with "#".
Test sends skip suppression checks and plan send limits, and do not block the real send to the same address. Returns { success: true, email_id }.
Queue for sendingPOST/v1/campaigns/{id}/send
Permission: write. No request body required. The campaign must be in draft or scheduled status (409 otherwise) and have: subject, content (content_html or template_id), from_email, and at least one contact group (400 otherwise).
| Scenario | What happens |
|---|---|
| Immediate (send_type not set or "immediate") | Sets status to "sending", processes the first chunk (1000 contacts). Remaining emails continue in background via cron. |
| Scheduled (send_type: "scheduled" + scheduled_at) | Sets status to "scheduled" and returns { campaign, scheduled: true }. The cron job processes it at the scheduled_at time. |
For an immediate send, the response is 200 with { campaign, total_queued, total_skipped, done, message }. done is true when every recipient was processed ("Broadcast sent successfully"); otherwise the remaining recipients are processed in the background ("Broadcast is being sent. Remaining emails will be processed in the background."). Unsubscribed, blocked and suppressed contacts are skipped. If the audience has no eligible contacts, the campaign is marked sent with total_queued 0.
| Situation | Response |
|---|---|
| Campaign not found | 404. |
| Status is not draft or scheduled | 409. |
| Missing subject, content, audience or from_email | 400. |
| Plan without broadcasts, not enough remaining monthly email quota for the whole audience, from domain not verified, or linked template missing or empty | 500 with { error } describing the reason (for example "Not enough email quota. Campaign needs ..."). The campaign goes back to draft so you can fix the issue and retry. |
| Monthly, daily or hourly send limit already reached (or reached mid-send) | 200 with done: false. The campaign stays in sending status and the cron retries every minute until the limit allows. |
Delete a campaignDELETE/v1/campaigns/{id}
Permission: admin. Only draft campaigns can be deleted. Non-draft campaigns return 409.
List unsubscribed contactsGET/v1/campaigns/unsubscribed
Permission: read. Returns contacts that have unsubscribed from broadcasts, with the campaign name, newest first. Query params: limit (default 50, max 100) and offset. Returns { unsubscribed: [...], pagination: { total, limit, offset } }.
Resubscribe a contactDELETE/v1/campaigns/unsubscribed
Permission: write. Removes the unsubscribe record. Body: { "id": "record_id" } or { "email": "user@example.com" }. Returns { resubscribed: true, removed }, or 404 if no record matches.
Get broadcast settingsGET/v1/campaigns/settings
Permission: read. Returns { settings } with the account-wide broadcast settings (logo, footer and sender defaults) listed below.
Update broadcast settingsPATCH/v1/campaigns/settings
Permission: write. Only the fields below are accepted; other keys are ignored, and a body with none of them returns 400. Returns the updated { settings }.
| Field | Type | Description |
|---|---|---|
| logo_url | string | Account logo URL used in broadcasts. Also set by uploading with purpose "logo". |
| broadcast_logo_alignment | string | left, center or right (400 otherwise). |
| broadcast_footer_alignment | string | left, center or right (400 otherwise). |
| broadcast_unsubscribe_message | string | Text shown with the unsubscribe link. |
| broadcast_company_name | string | Company name shown in the footer. |
| broadcast_company_address | string | Street address shown in the footer. |
| broadcast_company_city | string | City. |
| broadcast_company_state | string | State or region. |
| broadcast_company_zip | string | Postal code. |
| broadcast_company_country | string | Country. |
| broadcast_default_domain | string | Default sending domain for new broadcasts. |
| broadcast_default_sender_name | string | Default sender display name. |
| broadcast_default_from_prefix | string | Default local part of the sender address (the part before @). |
| broadcast_default_reply_to | string | Default reply-to address. |
Upload a broadcast imagePOST/v1/uploads/broadcast-image
Permission: write. Uploads an image (sent as base64 JSON) for use in campaign content or as the account logo. Returns 201 with { url, file_name }; use url in your content_html.
| Field | Type | Required | Description |
|---|---|---|---|
| file_name | string | Yes | Original file name. The stored file gets a generated name. |
| content_type | string | Yes | image/jpeg, image/png, image/gif, image/webp or image/svg+xml. |
| file_data | string | Yes | Base64-encoded file content. A data URI (data:image/png;base64,...) is also accepted. |
| entity_id | string | No | Optional ID used to group the file in storage (for example the campaign ID). Characters other than letters, digits, _ and - are removed. |
| purpose | string | No | content (default) or logo. Max size: 5 MB for content, 2 MB for logo (400 otherwise). With logo, the uploaded URL also replaces the account logo (logo_url). |