# Campaigns

> Broadcast email campaigns to contact groups.

Source: https://tickos.dev/docs/campaigns

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.

> **Plan requirement:** Sending campaigns requires a plan with broadcasts enabled (Starter or higher). On the Free plan you can create and configure campaigns, but /send fails (see Queue for sending).

## GET /v1/campaigns -- List 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. |

Each item also includes contact_group_id, total_delivered, total_opened, total_clicked, total_bounced, scheduled_at, sending_started_at, sending_completed_at, created_at, updated_at, contact_group { id, name, color } and template { id, name }.:

```json
{
  "campaigns": [
    {
      "id": "...",
      "name": "July Newsletter",
      "subject": "What is new in July",
      "status": "sent",
      "template_id": null,
      "contact_group_ids": ["group_id_1"],
      "total_recipients": 1200,
      "total_sent": 1180,
      "...": "..."
    }
  ],
  "pagination": { "total": 1, "limit": 20, "offset": 0 }
}
```

## GET /v1/campaigns/{id} -- Get a campaign

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.

## POST /v1/campaigns -- Create a campaign

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

> **Template validation:** template_id is not checked against your templates when the campaign is created. PATCH /v1/campaigns/{id} validates it and returns 404 for a template that does not exist in your account, so set it via PATCH if you want the check.

## PATCH /v1/campaigns/{id} -- Configure the campaign

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

```bash
curl -X PATCH https://api.tickos.dev/v1/campaigns/{id} \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "What is new in July",
    "from_email": "news@yourdomain.com",
    "from_name": "Acme News",
    "content_html": "<h1>July Newsletter</h1><p>Hello!</p>",
    "contact_group_ids": ["group_id_1", "group_id_2"]
  }'
```

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

```bash
# Step 1: Create the campaign (returns the campaign id)
curl https://api.tickos.dev/v1/campaigns \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{ "name": "July Newsletter" }'

# Step 2: Configure content, sender and audience
curl -X PATCH https://api.tickos.dev/v1/campaigns/{id} \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "What is new in July",
    "from_email": "news@yourdomain.com",
    "from_name": "Acme News",
    "content_html": "<h1>July Newsletter</h1><p>Hello!</p>",
    "contact_group_ids": ["group_id_1"]
  }'

# Step 3: Send immediately
curl -X POST https://api.tickos.dev/v1/campaigns/{id}/send \
  -H 'Authorization: Bearer sk_your_api_key'
```

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

```bash
# Step 1: Create the campaign linked to a template
curl https://api.tickos.dev/v1/campaigns \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{ "name": "July Newsletter", "template_id": "etpl_xxxxxxxx" }'

# Step 2: Configure sender and audience (content comes from the template)
curl -X PATCH https://api.tickos.dev/v1/campaigns/{id} \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "What is new in July",
    "from_email": "news@yourdomain.com",
    "from_name": "Acme News",
    "contact_group_ids": ["group_id_1"]
  }'

# Step 3 (optional): Send a test to yourself first
curl -X POST https://api.tickos.dev/v1/campaigns/{id}/test-send \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{ "test_email": "me@example.com" }'

# Step 4: Send immediately
curl -X POST https://api.tickos.dev/v1/campaigns/{id}/send \
  -H 'Authorization: Bearer sk_your_api_key'
```

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

The campaign stays in "scheduled" status until the cron processes it at the scheduled_at time.:

```bash
# Step 1: Create the campaign
curl https://api.tickos.dev/v1/campaigns \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Black Friday Sale" }'

# Step 2: Configure everything including schedule
curl -X PATCH https://api.tickos.dev/v1/campaigns/{id} \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "Black Friday: 50% off everything!",
    "from_email": "promo@yourdomain.com",
    "from_name": "Acme Store",
    "template_id": "etpl_xxxxxxxx",
    "contact_group_ids": ["group_id_1", "group_id_2"],
    "send_type": "scheduled",
    "scheduled_at": "2026-11-28T08:00:00Z"
  }'

# Step 3: Queue the scheduled send
curl -X POST https://api.tickos.dev/v1/campaigns/{id}/send \
  -H 'Authorization: Bearer sk_your_api_key'

# Response: { "campaign": { "status": "scheduled", ... }, "scheduled": true }
```

> **Cancel a scheduled campaign:** To cancel a scheduled campaign, PATCH it with { "status": "cancelled" } or { "status": "draft" } to re-edit it.

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

> **Variable priority:** System variables (from the contact record) are resolved first, so a template default for name, first_name, email or phone is never used. The {{unsubscribe_url}} variable works in both modes (with or without template).

> **template_id vs content_html:** If both template_id and content_html are set, template_id takes priority at send time. To go back to using content_html directly, set template_id to null via PATCH.

## POST /v1/campaigns/{id}/test-send -- Send a test email

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

```bash
curl -X POST https://api.tickos.dev/v1/campaigns/{id}/test-send \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{ "test_email": "me@example.com" }'
```

## POST /v1/campaigns/{id}/send -- Queue for sending

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

> **Scheduled sends:** For scheduled campaigns, the plan, quota and domain checks run when the cron starts sending, not when you call /send. If a check fails at that point, the campaign returns to draft status.

## DELETE /v1/campaigns/{id} -- Delete a campaign

Permission: admin. Only draft campaigns can be deleted. Non-draft campaigns return 409.

## GET /v1/campaigns/unsubscribed -- List unsubscribed contacts

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

## DELETE /v1/campaigns/unsubscribed -- Resubscribe a contact

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 /v1/campaigns/settings -- Get broadcast settings

Permission: read. Returns { settings } with the account-wide broadcast settings (logo, footer and sender defaults) listed below.

## PATCH /v1/campaigns/settings -- Update broadcast 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. |

```bash
curl -X PATCH https://api.tickos.dev/v1/campaigns/settings \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "broadcast_company_name": "Acme Inc",
    "broadcast_company_country": "US",
    "broadcast_logo_alignment": "center"
  }'
```

## POST /v1/uploads/broadcast-image -- Upload a 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). |

```bash
curl https://api.tickos.dev/v1/uploads/broadcast-image \
  -H 'Authorization: Bearer sk_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "file_name": "hero.png",
    "content_type": "image/png",
    "file_data": "iVBORw0KGgoAAAANSUhEUgAA...",
    "purpose": "content"
  }'
```

> **Note:** Each recipient gets a unique unsubscribe link through the {{unsubscribe_url}} variable. TickOS does not add a footer for you: include {{unsubscribe_url}} in your content_html or template (the unsubscribe footer of the dashboard editor already uses it). Unsubscribed contacts are excluded from future campaigns.
