Campaigns
Create and send broadcast email campaigns to audiences.
Campaigns let you send one email to an entire audience. You can schedule campaigns in advance or send immediately.
Campaign lifecycle
draft → scheduled → sending → sent
└→ cancelled
Create campaign
POST /v1/campaigns
Requires full_access scope.
Request body
{
"name": "March Newsletter",
"subject": "What's new in March",
"from": "Newsletter <news@acme.com>",
"audience_id": "aud_abc123",
"html": "<h1>March Update</h1><p>Here's what happened this month...</p>",
"unsubscribe_url": "https://acme.com/unsubscribe",
"scheduled_at": "2025-03-15T09:00:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Campaign name (internal). |
subject | string | Yes | Email subject line. |
from | string | Yes | Sender address. Domain must be verified. |
audience_id | string | Yes | ID of the audience to send to. |
html | string | No* | HTML body. At least one of html or text required. |
text | string | No* | Plain-text body. |
template_id | string | No | Use a saved template. |
unsubscribe_url | string | Yes | Unsubscribe URL for CAN-SPAM compliance. |
scheduled_at | string | No | ISO 8601 datetime. Omit to send immediately. |
Response
{
"id": "camp_abc123",
"name": "March Newsletter",
"status": "scheduled",
"audience_id": "aud_abc123",
"subject": "What's new in March",
"scheduled_at": "2025-03-15T09:00:00Z",
"created_at": "2025-03-12T09:00:00Z"
}
Get campaign
GET /v1/campaigns/:id
Requires read_only scope.
List campaigns
GET /v1/campaigns
Requires read_only scope.
Update campaign
PUT /v1/campaigns/:id
Requires sending_access scope. Only campaigns in draft or scheduled status can be updated.
Schedule campaign
POST /v1/campaigns/:id/schedule
Requires sending_access scope. Schedules a draft campaign to be sent at a specific time.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
scheduled_at | string | Yes | ISO 8601 datetime to schedule the campaign. |
Response
{
"id": "cmp_abc123",
"status": "scheduled"
}
Send campaign immediately
POST /v1/campaigns/:id/send
Requires full_access scope. Immediately enqueues a draft or scheduled campaign for sending.
Response
{
"id": "cmp_abc123",
"status": "sending"
}
Unschedule campaign
POST /v1/campaigns/:id/unschedule
Requires full_access scope. Moves a scheduled campaign back to draft so it can be edited or rescheduled.
Response
{
"id": "cmp_abc123",
"status": "draft"
}
Pause campaign
POST /v1/campaigns/:id/pause
Requires full_access scope. Pauses a campaign that is currently sending.
Response
{
"id": "cmp_abc123",
"status": "paused"
}
Resume campaign
POST /v1/campaigns/:id/resume
Requires full_access scope. Resumes sending a paused campaign.
Response
{
"id": "cmp_abc123",
"status": "sending"
}
Cancel campaign
POST /v1/campaigns/:id/cancel
Requires full_access scope. Cancels a scheduled campaign before it starts sending. Cannot cancel a campaign that is already in sending or sent status.
Delete campaign
DELETE /v1/campaigns/:id
Requires full_access scope. Only draft or cancelled campaigns can be deleted.
Campaign stats
GET /v1/campaigns/:id
Requires read_only scope. Delivery statistics are returned on the campaign object as stats by Get campaign; there is no separate stats endpoint.
{
"campaign_id": "camp_abc123",
"total": 4200,
"sent": 4198,
"delivered": 4100,
"opened": 1722,
"clicked": 492,
"bounced": 98,
"complained": 4,
"unsubscribed": 12
}
List campaign recipients
GET /v1/campaigns/:id/recipients
Requires read_only scope. Lists every recipient for a campaign with their delivery status and timestamps. Before a campaign sends, this returns a preview of the audience or segment members it will target.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (1–100). |
cursor | string | Pagination cursor from the previous response's next_cursor. | |
status | string | Filter by recipient status: pending, queued, sent, delivered, opened, clicked, bounced, complained, unsubscribed, failed, skipped. |
Response
{
"data": [
{
"id": "rcpt_abc123",
"campaign_id": "cmp_abc123",
"contact_id": "con_abc123",
"email": "alice@example.com",
"status": "delivered",
"email_id": "email_abc123",
"sent_at": "2025-03-15T09:00:01Z",
"delivered_at": "2025-03-15T09:00:05Z",
"opened_at": "2025-03-15T09:20:30Z",
"clicked_at": null,
"created_at": "2025-03-15T09:00:00Z",
"preview": false
}
],
"has_more": false,
"next_cursor": null
}
preview is true on rows surfaced from the target audience before the campaign has sent. Pagination is cursor-based. Pass next_cursor as the cursor parameter to get the next page.
Plan limits
| Plan | Campaigns |
|---|---|
| Free | 3 |
| Pro | 25 |
| Business | Unlimited |
| Enterprise | Unlimited |
Marketing emails sent via campaigns count toward the marketingEmailsPerMonth quota. Free plan does not allow marketing emails (quota = 0).
Get A/B test results
GET /v1/campaigns/:id/ab-test/results
Requires read_only scope. Returns per-variant sent / opened / clicked counts and rates, plus the winner state and holdback size, for an A/B-enabled campaign. See A/B testing for the object shape.
Response
{
"enabled": true,
"variant_a": { "sent": 400, "opened": 120, "clicked": 30, "open_rate": 0.30, "click_rate": 0.075 },
"variant_b": { "sent": 400, "opened": 160, "clicked": 28, "open_rate": 0.40, "click_rate": 0.070 },
"winner_variant": null,
"winner_evaluated_at": null,
"winner_metric": "open_rate",
"holdback_total": 200,
"sample_percent": 80,
"split_percent": 50,
"evaluate_after_minutes": 60
}
Pick A/B test winner
POST /v1/campaigns/:id/ab-test/pick-winner
Requires sending_access scope. Declares a winner manually, sets winner_variant and winner_evaluated_at, and enqueues the winning variant's content to any holdback recipients.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
variant | string | Yes | Variant to declare the winner: a or b. |
Example request
{ "variant": "b" }
Response
Returns the same shape as Get A/B test results with the winner now set.
A/B testing
A/B-test a broadcast by setting an ab_test object on create or update. Variant B can override the subject, preview text, HTML, plain text, and/or template. Any unset variant-B field falls back to variant A's value.
Create with A/B test
curl -X POST https://api.sendry.online/v1/campaigns \
-H "Authorization: Bearer sn_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "March newsletter",
"subject": "What is new in March",
"from": "news@acme.com",
"audience_id": "aud_abc123",
"html": "<p>Variant A body</p>",
"ab_test": {
"enabled": true,
"split_percent": 50,
"sample_percent": 80,
"winner_metric": "open_rate",
"evaluate_after_minutes": 60,
"variant_b": {
"subject": "March: a fresh take",
"html": "<p>Variant B body</p>"
}
}
}'
| Field | Type | Required | Description |
|---|---|---|---|
enabled | bool | yes | Turns A/B testing on for this campaign. |
split_percent | number | no | 1–99. % of the test sample receiving variant B. Default 50. |
sample_percent | number | no | 1–100. % of the audience that participates in the test. Rest is holdback. |
winner_metric | string | cond. | "open_rate" or "click_rate". Required when sample_percent < 100. |
evaluate_after_minutes | number | no | 5–10080. How long to wait before auto-picking. Default 60. |
variant_b.subject | string | no | Override for B's subject. Falls back to A. |
variant_b.preview_text | string | no | Override for B's preview text. |
variant_b.html | string | no | Override for B's HTML. |
variant_b.text_content | string | no | Override for B's plain text. |
variant_b.template_id | string | no | Override template for B. |
When enabled is true, at least one variant_b override must be set.
Get A/B test results
GET /v1/campaigns/{id}/ab-test/results
{
"enabled": true,
"variant_a": { "sent": 400, "opened": 120, "clicked": 30, "open_rate": 0.30, "click_rate": 0.075 },
"variant_b": { "sent": 400, "opened": 160, "clicked": 28, "open_rate": 0.40, "click_rate": 0.070 },
"winner_variant": null,
"winner_evaluated_at": null,
"winner_metric": "open_rate",
"holdback_total": 200,
"sample_percent": 80,
"split_percent": 50,
"evaluate_after_minutes": 60
}
Manually pick a winner
Requires sending_access scope. Setting a winner enqueues the holdback recipients (if any) to receive the winning variant's content.
curl -X POST https://api.sendry.online/v1/campaigns/cp_abc/ab-test/pick-winner \
-H "Authorization: Bearer sn_live_..." \
-H "Content-Type: application/json" \
-d '{ "variant": "b" }'
Returns the same shape as GET /ab-test/results with the winner now set.
Auto-pick
When sample_percent < 100 and winner_metric is set, the ab-test-evaluator worker polls every 5 minutes after send_started_at + evaluate_after_minutes and picks the winner deterministically (higher rate wins; ties → "a"). The holdback recipients then receive the winning variant's content via the same campaign-send worker.