Messaging Campaigns
Campaigns let you send bulk messages to contact lists across WhatsApp, Telegram, Messenger, Instagram, and SMS. This guide covers creating, scheduling, sending, and analyzing campaigns.
Required Scopes
| Scope | Purpose |
|---|---|
campaigns:read | List and view campaigns and analytics, check warm-up status, preview messages per recipient |
contacts:read | Needed in addition to campaigns:read for the previews that show contact details: POST /campaigns/preview/recipients, POST /campaigns/preview and POST /campaigns/preview-segment |
campaigns:create | Create new campaigns |
campaigns:update | Edit campaign content/settings and schedule/cancel-schedule |
campaigns:send | Send, pause, resume, and cancel campaigns |
campaigns:delete | Delete draft campaigns |
Campaign Lifecycle
| Status | Description |
|---|---|
draft | Campaign is being prepared, not yet sent |
scheduled | Campaign is scheduled for future delivery |
sending | Campaign is actively being sent |
throttled | Sending is on hold temporarily and continues automatically (see throttle_reason, e.g. daily_batch_limit between the days of a split send) |
sent | Campaign has finished sending |
paused | Campaign was paused during sending |
cancelled | Campaign was cancelled before completion |
Campaign Pricing
Campaign messages are billed at the same per-unit rate as conversation messages, and draw from the same monthly included pool (shared with conversation messages and AI credits) that comes with your plan's base fee:
| Plan | Base fee | Included pool / month | Per campaign message beyond the pool |
|---|---|---|---|
| Basic | EUR 49 | 2,500 | EUR 0.020 |
| Professional | EUR 79 | 2,500 | EUR 0.020 |
| Scale | EUR 199 | 10,000 | EUR 0.015 |
| Enterprise | EUR 499 | 40,000 | EUR 0.010 |
| API Only | EUR 9 per connected channel | 1,000 per connected channel | EUR 0.005 |
Incoming messages are not charged. Campaign emails have their own (lower) rate — see Email Campaigns. Carrier/network fees for SMS are passed through on top of these rates.
Create a Campaign
POST /api/v1/campaigns
Campaign content is per-channel, supplied via the channel_content object. Include only the channels the campaign targets. Each channel has its own content shape — text channels (Telegram, SMS, Messenger) take a message_text, WhatsApp requires a pre-approved template, and browser push takes a title/body.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Campaign name |
list_ids | array | Yes* | Array of contact list IDs to send to. *Not needed when you send audience_conditions (then it is ignored). |
audience_conditions | object | No | Audience Builder filter: narrows the subscribers of one or more lists. See Audience Builder. |
channel_content | object | Yes* | Per-channel content (see shapes below). *Legacy single-channel campaigns may instead use top-level message_text + message_type. |
description | string | No | Internal description |
scheduled_at | string | No | ISO 8601 datetime. Can also be set later via the dedicated schedule endpoint. |
timezone | string | No | IANA timezone (e.g. Europe/Berlin) for scheduled_at |
should_skip_duplicates | boolean | No | Default true. Skip contacts already messaged within the duplicate window. |
duplicate_window_hours | integer | No | Default 24. Window for duplicate skipping. |
send_rate_limit | integer | No | Send speed in messages per minute (1–60000). null = as fast as the channels allow. See Send speed and batches. |
send_batch_size | integer | No | Send in batches of this many recipients (at least 1); the campaign pauses after each batch. null = no batches. |
send_split_config | object | No | WhatsApp only. Spread the send over several days — see Split sending. Omit or null to send to everyone at once. |
acknowledge_warmup_warning | boolean | No | Default false. Only used together with scheduled_at: confirms a large WhatsApp send while the number is still warming up (see Warm-up acknowledgement). |
A campaign created with scheduled_at is scheduled right away, so it goes through the same checks as the schedule endpoint: 409 warmup_acknowledgement_required if it needs a warm-up acknowledgement and acknowledge_warmup_warning is not true, and 422 split_plan_too_long if its split plan would run too long. In both cases no campaign is created, so you can safely repeat the request.
Every ID in list_ids must be a list in your workspace. If one is not, the request is rejected with 400 "One or more lists not found". This applies both when you create a campaign and when you change its list_ids with PUT /api/v1/campaigns/{campaign_id}, and a rejected update leaves the campaign unchanged.
Audience Builder
Instead of sending to whole lists, you can send to the subscribers of one or more lists who match extra conditions, for example "newsletter subscribers who speak German and joined in the last 30 days, except those on the VIP list". Send audience_conditions with the same condition format as a dynamic list's segment_conditions:
{
"operator": "AND",
"groups": [
{"type": "condition", "field": "has_optin", "operator": "equals", "value": true,
"list_id": "d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "channel_type": "any"},
{"type": "condition", "field": "has_optin", "operator": "equals", "value": false,
"list_id": "e2f3a4b5-6c7d-4e8f-9a0b-1c2d3e4f5a6b", "channel_type": "any"},
{"type": "condition", "field": "language", "operator": "equals", "value": "de"},
{"type": "condition", "field": "created_at", "operator": "within_last_days", "value": 30}
]
}
Rules:
- The top level must be
"operator": "AND"and must contain at least one list membership: ahas_optincondition with"operator": "equals","value": trueand thelist_idof a static or newsletter list in your workspace. To combine several lists, put only such conditions in a group:"operator": "OR"= subscribed to any of the lists,"AND"= subscribed to all of them. Every audience starts from list subscribers; there is no "conditions only" audience. has_optinonly matches active subscriptions (pending double opt-ins never count).channel_typeis"any"or one channel; a campaign still only sends on its own channels, to contacts subscribed there.- Everything else in the tree narrows the audience down or excludes contacts (
has_optinwith"value": falseexcludes a list's subscribers). Values forhas_optinmust be real booleans. - When
audience_conditionsis set, the campaign's lists are taken from the membership lists and anylist_idsyou send are ignored. The response returns both. - On
PUT, omitaudience_conditionsto leave it unchanged, or sendnullto remove it (the campaign keeps its current lists). It can only be changed while the campaign isdraftorscheduled. - The audience is evaluated when the campaign is sent (also for scheduled sends), so contacts who match by then are included.
Errors (422, body {"detail": {"code": ..., "message": ...}}):
| Code | Meaning |
|---|---|
audience_list_membership_required | No list membership at the top level, or the top-level operator is not AND |
audience_membership_list_invalid | A membership list does not exist in your workspace or is not a static or newsletter list |
audience_conditions_invalid | The tree itself is malformed |
A structurally invalid tree (for example an unknown group operator) can also return the standard 422 validation error. To keep the audience for later, create a dynamic list with the same tree: POST /api/v1/lists with {"name": "...", "list_type": "dynamic", "segment_conditions": <tree>}. Dynamic lists follow the same membership rule.
channel_content shapes
| Channel | Key | Fields |
|---|---|---|
| Telegram | telegram | message_text (required), message_type (text/image/video/document), attachment_id, channel_id |
| SMS | sms | message_text (required), channel_id |
| Messenger | messenger | message_text (required), message_type, attachment_id, channel_id |
whatsapp | channel_ids (required), template_id (required), template_name (required), template_language, variable_mapping | |
| Browser Push | browser_push | title (required), text (required), url, image_attachment_id, channel_id |
Every attachment you reference (attachment_id, image_attachment_id, the WhatsApp header_image_attachment_id and each carousel card's header_image_attachment_id / header_video_attachment_id, and the top-level attachment_id) must be an attachment of your workspace. Otherwise the create or update is rejected with 404 "Attachment {id} not found", the same as sending a message, and nothing is saved. The WhatsApp header fields also accept an external https:// URL. On an update, only references you add or change are checked.
Business-initiated WhatsApp campaigns can only send approved message templates — you cannot send free-form text. See WhatsApp Templates for how to create and reference a template, then pass its template_id/template_name in channel_content.whatsapp.
Create a Draft (Telegram)
curl -X POST "https://api.sendseven.com/api/v1/campaigns" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"name": "Weekend Sale Reminder",
"list_ids": ["list_active_customers", "list_newsletter"],
"channel_content": {
"telegram": {
"message_type": "text",
"message_text": "Our weekend sale starts tomorrow! 30% off all items."
}
}
}'
Response (201 Created)
The created campaign starts in draft status. Recipient and delivery counters are returned as discrete fields:
{
"id": "c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8",
"name": "Weekend Sale Reminder",
"status": "draft",
"channel_content": {
"telegram": { "message_type": "text", "message_text": "Our weekend sale..." }
},
"list_ids": ["list_active_customers", "list_newsletter"],
"total_recipients": 3800,
"sent_count": 0,
"delivered_count": 0,
"read_count": 0,
"failed_count": 0,
"scheduled_at": null,
"timezone": null,
"created_at": "2026-02-10T16:30:00Z",
"updated_at": "2026-02-10T16:30:00Z"
}
Preview a Campaign per Recipient
Before you save or send, you can see exactly what each recipient will get: the personalization variables, template header, body and buttons are filled in the same way as for the real send. These endpoints work without a saved campaign, so you can use them while you build one. They also work for an existing campaign with unsaved changes. They are read-only: nothing is saved, nothing is sent, and nothing is billed.
| Endpoint | Required scopes |
|---|---|
POST /campaigns/audience/channels | campaigns:read (plus contacts:read when the body has audience_conditions) |
POST /campaigns/preview/recipients | campaigns:read and contacts:read |
POST /campaigns/preview | campaigns:read and contacts:read |
POST /campaigns/preview/variable-stats | campaigns:read (plus contacts:read when the body has audience_conditions) |
POST /campaigns/preview-segment | campaigns:read and contacts:read |
/preview/recipients, /preview and /preview-segment show contact details (names, phone numbers, email addresses and custom fields), so they also need contacts:read. A token that has only one of the two scopes gets 403. An audience_conditions tree filters on contact data (tags, custom fields, languages), so /audience/channels and /preview/variable-stats also need contacts:read whenever the body contains one.
The request bodies of /audience/channels, /preview/recipients and /preview/variable-stats accept ignores_newsletter_subscriptions only for compatibility. The only accepted value is false, which is also the default. true ("send to all contacts") is no longer supported and returns 422 with detail.code ignores_newsletter_subscriptions_not_supported. Creating and updating campaigns rejects it too.
Channels that reach your lists
POST /api/v1/campaigns/audience/channels
{ "list_ids": ["d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a"] }
{
"items": [
{ "channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e", "channel_type": "whatsapp", "channel_name": "Shop WhatsApp", "subscriber_count": 3800 },
{ "channel_id": "a7b8c9d0-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "channel_type": "telegram", "channel_name": "Shop Bot", "subscriber_count": 412 }
]
}
Only active channels with at least one reachable subscriber in the selected lists are returned. Use these channels for the campaign's channel_content.
All preview endpoints on this page also accept audience_conditions instead of list_ids (same rules and error codes as Audience Builder), so you can show a live count while the audience is being built.
Find recipients to preview
POST /api/v1/campaigns/preview/recipients
{
"list_ids": ["d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a"],
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"search": "müller",
"page": 1,
"page_size": 10
}
{
"items": [
{ "contact_id": "e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "display_name": "Anna Müller", "phone": "+491701234567", "email": "[email protected]", "channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e" }
],
"pagination": { "total": 1, "page": 1, "page_size": 10, "total_pages": 1, "has_next": false, "has_prev": false }
}
These are the same recipients the send would reach: subscribers of the lists, without opt-outs, routed to the channel. channel_id and search are optional. search matches name, phone or email, and case does not matter. page_size is at most 50.
HubSpot segment lists
/preview, /preview/recipients and /preview/variable-stats never call HubSpot. For a HubSpot segment list, /preview/recipients only shows contacts that are already stored in your workspace:
- Cached segments (refreshed on a schedule) show the members from the last refresh. The send uses the same members.
- Segments resolved at send time show the contacts that the most recent campaign sent to that list reached. A list that has never been sent to shows no recipients, and HubSpot members that the send would add as new contacts are never shown. The real send still fetches the current members from HubSpot, so its audience can be larger or different.
/audience/channels is different: it counts HubSpot segment lists the same way the send does, including current HubSpot members, so channels are not hidden because a list has not been sent to yet. Counting never creates contacts.
Render the message for one recipient
POST /api/v1/campaigns/preview
{
"contact_id": "e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"list_ids": ["d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a"],
"draft": {
"channel_content": {
"whatsapp": {
"channel_ids": ["b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e"],
"template_id": "f1e2d3c4-b5a6-4978-8c9d-0e1f2a3b4c5d",
"template_name": "autumn_sale",
"template_language": "de",
"variable_mapping": { "1": "contact.first_name" }
}
}
}
}
drafttakes the same content fields as updating a campaign:channel_content,message_text,message_type,attachment_id, thewhatsapp_template_*fields, and so on.- Add
campaign_idto preview an existing campaign. Only the fields you send indraftreplace its saved values, and the saved campaign itself is not changed. - Without
campaign_id, only thedraftis rendered. - The contact does not need to be on the lists, but the contact and the channel must belong to your workspace.
{
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"channel_type": "whatsapp",
"message_type": "whatsapp_template",
"text": null,
"media": null,
"template": {
"name": "autumn_sale",
"language": "de",
"header": { "format": "IMAGE", "text": null, "media_url": "https://cdn.example.com/autumn.jpg" },
"body": "Hallo Anna, unser Herbst-Sale startet heute!",
"footer": "Abmelden: STOP",
"buttons": [ { "type": "URL", "text": "Zum Shop", "url": "https://shop.example.com/sale", "phone_number": null } ],
"cards": null
},
"variables": [
{ "placeholder": "{{1}}", "source": "contact.first_name", "value": "Anna", "status": "resolved" }
],
"has_unresolved": false,
"recipient": { "contact_id": "e5f6a7b8-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "display_name": "Anna Müller", "address": "+491701234567" }
}
For a normal (non-template) message, text holds the final message text and media the attachment (type, url, filename); template is then null. recipient.address is the phone number or handle the message would go to on this channel.
Each entry in variables has a status:
status | Meaning |
|---|---|
resolved | The recipient has a value for this field. |
empty | The field exists but is empty for this recipient. |
missing_field | The field does not exist for this recipient. |
fallback | The fallback value was used. |
has_unresolved is true if any variable is not resolved.
Missing values across the audience
POST /api/v1/campaigns/preview/variable-stats
{
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"list_ids": ["d1e2f3a4-5b6c-4d7e-8f9a-0b1c2d3e4f5a"],
"draft": { "channel_content": { "whatsapp": { "variable_mapping": { "1": "contact.first_name" } } } }
}
{
"scanned": 3800,
"total": 3800,
"capped": false,
"variables": [
{ "placeholder": "{{1}}", "source": "contact.first_name", "missing_count": 112, "empty_count": 9, "fallback_count": 0 }
],
"counts_exclude_hubspot": false,
"excluded_hubspot_segment_list_ids": []
}
This counts, per variable, how many recipients on the channel would get an empty, missing or fallback value. The same campaign_id and draft rules apply as for POST /preview. For large audiences only part of the audience is checked: capped is then true, and scanned shows how many recipients were checked out of total.
HubSpot segment lists are left out of these counts:
| Field | Meaning |
|---|---|
counts_exclude_hubspot | true when at least one selected list is a HubSpot segment and was left out of the counts |
excluded_hubspot_segment_list_ids | IDs of the HubSpot segment lists that were left out |
If every selected list is a HubSpot segment, the response has scanned: 0, total: 0 and an empty variables array.
Preview a segment
POST /api/v1/campaigns/preview-segment
Returns total_count (all contacts that match the segment filters) and preview_contacts, a sample with id, name, phone_number and email. limit sets the sample size. It defaults to 10 and can be at most 100; a larger value returns 422. Requires campaigns:read and contacts:read (403 otherwise).
WhatsApp Recipients Without a WhatsApp ID
WhatsApp campaign messages go only to a contact's whatsapp_id. A recipient who has a phone method but no whatsapp_id is left out of the WhatsApp send by default. Before October 2026 such recipients were sent to their phone number; this has changed for every caller, including the API.
GET /api/v1/campaigns/{campaign_id}/estimate-cost (scope campaigns:read) always returns whatsapp_phone_only_count: how many recipients of the campaign's WhatsApp channels have only a phone number. They are not included in recipient_count, recipients_by_channel, breakdown or the costs, which all describe the actual send. In whatsapp_resolved_via, phone is always 0.
To include them, pass whatsapp_phone_only_action to POST /send or POST /schedule:
| Value | Effect |
|---|---|
exclude (default) | They are left out. Nothing is changed on the contacts. |
add_whatsapp_id | Each one's phone number is first saved as their whatsapp_id, so they receive the campaign. A contact is skipped when another contact already has that WhatsApp ID (a contact method is never moved between contacts) or when the number is not valid. |
Both responses include whatsapp_ids_added:
{"whatsapp_ids_added": {"added": 41, "skipped_conflict": 2, "skipped_invalid": 1}}
All three counts are 0 with exclude. With POST /schedule, the WhatsApp IDs are saved when you schedule. Contacts with only a phone number who join the audience after that are left out of the scheduled send.
Schedule a Campaign
Scheduling uses a dedicated endpoint that accepts a local datetime plus an IANA timezone (the backend converts to UTC for storage). The scheduled time must be at least 5 minutes in the future. Requires the campaigns:update scope.
POST /api/v1/campaigns/{campaign_id}/schedule
curl -X POST "https://api.sendseven.com/api/v1/campaigns/c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8/schedule" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"scheduled_at": "2026-02-15T08:00:00",
"timezone": "Europe/Berlin"
}'
The campaign moves to scheduled status. To unschedule, call POST /api/v1/campaigns/{campaign_id}/cancel-schedule.
The body also accepts whatsapp_phone_only_action (exclude or add_whatsapp_id, see WhatsApp recipients without a WhatsApp ID) and acknowledge_warmup_warning (boolean, default false). The same warm-up rule as for sending applies when you schedule: if the campaign needs an acknowledgement you get 409 warmup_acknowledgement_required until you pass true. Confirm the warning when you schedule, not later: the acknowledgement is stored with the campaign, and the scheduled send goes out on time without asking again. The only exception is a significant audience growth between scheduling and the send time.
If you change the audience or content of a scheduled campaign afterwards (list_ids, channel_content, channel_filter, selected_channel_ids, send_split_config or any WhatsApp template field), the stored acknowledgement is removed, because it applied to the old version. Renaming or rescheduling keeps it. When the campaign is due and still needs an acknowledgement (none was given, it was removed by an edit, or the audience grew significantly since), it is not sent: it moves to paused with throttle_reason warmup_acknowledgement_required, and warmup_assessment.hold.kind on the campaign tells you why (not_acknowledged or audience_grew). Resume it with {"acknowledge_warmup_warning": true} (see Resume after a warm-up hold), or schedule it again with the acknowledgement.
A split plan that would run too long for the campaign's audience is rejected with 422 and detail.code split_plan_too_long (see Split sending).
Send a Campaign
Immediately begin sending a draft campaign. Requires the campaigns:send scope.
POST /api/v1/campaigns/{campaign_id}/send
curl -X POST "https://api.sendseven.com/api/v1/campaigns/c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8/send" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json"
The body is optional. Send {"acknowledge_warmup_warning": true} to confirm a large WhatsApp send while the number is still warming up (see Warm-up acknowledgement). Sending with no body is the same as sending false. whatsapp_phone_only_action (exclude by default, or add_whatsapp_id) decides what happens to WhatsApp recipients that have only a phone number, see WhatsApp recipients without a WhatsApp ID.
{
"success": true,
"campaign_id": "c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8",
"recipient_count": 3800,
"published_count": 3800,
"split": {
"days_total": 1,
"next_batch_at": null,
"scheduled_count": 0
},
"message": "Campaign queued for sending to 3800 recipients (3800 messages published)"
}
published_count is the number of messages queued now. For a split send, split.days_total is the number of sending days, split.scheduled_count the number of messages held for later days, and split.next_batch_at (UTC, ISO 8601) the start of the next daily batch. Without split sending, days_total is 1 and nothing is scheduled.
A sending campaign can be paused, resumed, or cancelled (all require campaigns:send):
POST /api/v1/campaigns/{campaign_id}/pause
POST /api/v1/campaigns/{campaign_id}/resume
POST /api/v1/campaigns/{campaign_id}/cancel
You can also pause a split send between two days, while it is throttled with throttle_reason daily_batch_limit. A paused campaign stays paused until you resume it. On resume, the remaining recipients are planned again from that moment: today's batch goes out, and the next batches follow on the next days, so a long pause does not end in one large burst.
Resume after a warm-up hold
POST /resume accepts an optional body {"acknowledge_warmup_warning": true}; no body is the same as false. The flag matters in two cases:
- The campaign is
pausedwiththrottle_reasonwarmup_acknowledgement_required. This happens when a scheduled campaign was due but still needed a warm-up acknowledgement, for example because it was edited after it was scheduled. - The campaign is resumed before any of its messages were prepared, for example because it failed at the very start.
In both cases resume runs the same warm-up check as sending. Without the acknowledgement it returns 409 warmup_acknowledgement_required and the campaign stays paused. With it, the acknowledgement is recorded and the campaign is sent like a new send: messages are prepared, and with split sending only day 1 goes out now. An ordinary pause needs no acknowledgement.
A campaign held because its audience grew (warmup_assessment.hold.kind audience_grew) always needs {"acknowledge_warmup_warning": true} to resume, even if an earlier acknowledgement is stored. A successful resume removes warmup_assessment.hold.
Retry Failed Recipients
Recipients that ended in failed are not re-sent by resume. Use retry-failed to reset them to pending and queue them again. The typical case is an SMS campaign that was paused automatically because the prepaid SMS balance was used up: top up, then retry only the recipients that failed for that reason. Requires the campaigns:send scope and a campaign in status completed, failed or paused.
POST /api/v1/campaigns/{campaign_id}/retry-failed
The body is optional. error_codes restricts the retry to recipients whose recorded error code matches; omit it to retry every failed recipient.
curl -X POST "https://api.sendseven.com/api/v1/campaigns/c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8/retry-failed" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{"error_codes": ["insufficient_sms_balance"]}'
{
"success": true,
"id": "c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8",
"requeued": 412
}
requeued is the number of failed recipients reset to pending.
- A
completedorfailedcampaign returns tosending, and recipients that were still pending are queued together with them. - A
pausedcampaign stays paused and the response adds"paused": true. The reset recipients wait with the rest of the unsent recipients until you callPOST /api/v1/campaigns/{campaign_id}/resume. A retry never resumes a paused campaign. After an SMS balance pause (throttle_reasoninsufficient_sms_balance), the sequence is: top up,retry-failed, thenresume.
A 400 is returned for campaigns in any other status. A second retry within 10 minutes returns 429 with {"detail": {"code": "retry_failed_cooldown", "message": "...", "retry_after_seconds": ...}} and a Retry-After header.
For a split send, the retried recipients join the remaining plan, which is planned again from that moment, just like resume: only today's batch goes out now, and the rest follows on the next days.
Error codes recorded on failed SMS recipients:
error_code | Meaning | Retry after |
|---|---|---|
insufficient_sms_balance | Prepaid SMS balance used up; the campaign is paused automatically | Topping up SMS credit |
sms_not_provisioned | The workspace has no SMS subaccount yet | Completing the SMS channel setup |
sms_duplicate_send | Identical message to this recipient within the last 180 seconds | 3 minutes |
sms_daily_limit_reached | Daily message limit for this recipient reached | The next day |
sms_rejected_by_carrier | The carrier refused the message | Fixing the Sender ID or content |
sms_rate_limited | The SMS provider temporarily limited the send rate | Lowering send_rate_limit or setting send_batch_size |
A WhatsApp, Messenger or Instagram campaign whose channel loses its Meta access (error 190) is paused automatically with throttle_reason channel_reconnect_required. Reconnect the channel, then call resume.
If more than 80% of an SMS campaign's delivery results (at least 20) are sms_rejected_by_carrier or sms_rate_limited, the campaign is paused automatically with throttle_reason set to the more frequent of the two. On a campaign sent in batches, retried recipients join the current batch and go out at the send speed.
RCS balance pauses
RCS sends are paid from the prepaid RCS wallet. When the wallet cannot pay for the next RCS recipients, the campaign is paused automatically with one of these throttle_reason values:
throttle_reason | Meaning | Resumes |
|---|---|---|
insufficient_rcs_balance | The RCS balance does not cover the next RCS recipients | Automatically after a top-up, or with POST /campaigns/{campaign_id}/resume |
rcs_wallet_blocked | The RCS wallet is not active (balance below zero, or frozen) | Automatically once the wallet is active and covers the next sends again |
rcs_postpaid_cap_reached | Monthly invoicing only: the monthly limit on invoiced RCS usage is reached | Automatically when the limit is raised or removed, or at the start of the next month |
Unlike SMS, the recipients not reached yet stay pending and are not marked failed, so retry-failed is not needed. Use GET /campaigns/{campaign_id}/rcs-estimate before sending to see what the RCS part of a campaign will reserve.
WhatsApp Warm-up and Split Sending
Meta limits how many people a WhatsApp number can message per day, and it watches how recipients react to a new number. A large first send from a new or little-used number can lower its quality rating or get it restricted. SendSeven helps in two ways:
- Warm-up check – before a WhatsApp campaign goes out, SendSeven checks whether the sending number is still warming up and whether the audience is large. If so, you must confirm the send explicitly.
- Split sending – send the campaign in daily batches (for example 250 on day 1, 500 on day 2, and so on) instead of all at once.
Both apply to WhatsApp channels only. For any channel you can also limit the send speed or send in batches you release yourself; that cannot be combined with split sending. See WhatsApp newsletter best practices for background.
Check warm-up status
GET /api/v1/campaigns/{campaign_id}/warmup-check
Requires campaigns:read. Works for drafts, before any message has been prepared. It uses the campaign's saved send_split_config.
To see how a different split would change the result without saving it, post the config instead:
POST /api/v1/campaigns/{campaign_id}/warmup-check
{ "send_split_config": { "mode": "ramp", "daily_batches": [250, 500, 1000], "batch_size": null, "send_time_local": "09:00" } }
An invalid config returns 422 with detail.code invalid_split_config. null checks the saved config, the same as the GET.
Response:
{
"applies": true,
"warmup": true,
"requires_acknowledgement": true,
"audience_size": 4200,
"recommended_daily_batches": [250, 500, 1000, 2000, 450],
"recommended_days": 5,
"guide_url": "https://docs.sendseven.com/guides/campaigns/whatsapp-newsletter-best-practices",
"acknowledged_at": null,
"acknowledged_by_user_id": null,
"split_config": null,
"thresholds": {
"large_audience": 250,
"channel_age_days": 30,
"min_completed_campaigns": 3,
"min_total_sent": 1000,
"ramp_first_batch": 250
},
"channels": [
{
"channel_id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
"channel_name": "Shop WhatsApp",
"audience_size": 4200,
"warmup": true,
"requires_acknowledgement": true,
"reasons": [
{ "code": "new_channel", "params": { "channel_age_days": 3 } },
{ "code": "audience_exceeds_tier", "params": { "tier_cap": 1000 } }
],
"meta_tier": "TIER_1K",
"tier_daily_cap": 1000,
"quality_rating": "GREEN",
"meta_limit_available": true,
"channel_age_days": 3,
"completed_campaigns": 0,
"total_sent": 0,
"recommended_daily_batches": [250, 500, 1000, 1000, 1000, 450]
}
]
}
| Field | Description |
|---|---|
applies | false when the campaign has no WhatsApp channel. The other fields are then neutral. |
warmup | At least one WhatsApp number is still warming up. |
requires_acknowledgement | Sending or scheduling needs acknowledge_warmup_warning: true. |
audience_size | WhatsApp recipients across all numbers of the campaign. |
recommended_daily_batches / recommended_days | Suggested daily ramp for the most limited number. You can pass it straight into send_split_config as {"mode": "ramp", "daily_batches": [...]}. |
thresholds | The limits the check currently uses (the values above are examples). |
channels[] | Result per WhatsApp number, with its reasons, Meta messaging tier (meta_tier, tier_daily_cap, null when unknown) and quality rating. |
assessment_error | Only present (true) when the check could not be completed. The campaign is then treated as needing an acknowledgement. |
Reason codes in channels[].reasons[].code:
| Code | Meaning |
|---|---|
new_channel | The number was connected recently (params.channel_age_days). |
few_previous_campaigns | Few campaigns have been completed on this number so far. |
low_send_history | Few campaign messages have been delivered through this number so far. |
audience_exceeds_tier | The audience is larger than the number's daily Meta messaging limit (params.tier_cap). |
quality_not_high | The number's Meta quality rating is not high. |
meta_limit_unavailable | Information only: the Meta tier/quality could not be read. The history checks still apply. |
Warm-up acknowledgement
An acknowledgement is needed when a number is warming up and its audience is large, unless split sending already keeps that number's first day small. In that case POST /send, POST /schedule, POST /campaigns with scheduled_at, and POST /resume after a warm-up hold return 409 and nothing is sent:
{
"detail": {
"code": "warmup_acknowledgement_required",
"message": "This WhatsApp number is still warming up and the campaign is large. ...",
"warmup": { "applies": true, "requires_acknowledgement": true, "...": "same shape as the warmup-check response" }
}
}
You then have two options:
- Split the send – save a
send_split_config(for example the recommended ramp) withPUT /api/v1/campaigns/{campaign_id}and send again. - Send anyway – repeat the call with
{"acknowledge_warmup_warning": true}.
The acknowledgement is recorded on the campaign in warmup_acknowledged_at (UTC) and warmup_acknowledged_by_user_id (the user behind the token), together with the audience size you confirmed (warmup_assessment.acknowledgement). The warning is a safeguard, not a block: with the acknowledgement the campaign is sent as requested.
A stored acknowledgement keeps counting: a later POST /send, POST /schedule or POST /resume of the same campaign, and the scheduled send itself, do not ask again. It is removed when you change the campaign's audience or content (see Schedule a Campaign).
Audience growth after the acknowledgement
Your lists can grow between the acknowledgement and the send. If the WhatsApp audience grew by more than 20% and by at least 250 recipients since you confirmed, and the campaign still needs an acknowledgement, the stored acknowledgement no longer counts:
POST /send,POST /scheduleandPOST /resumewithoutacknowledge_warmup_warning: truereturn409.detail.warmup.reasonsthen containsaudience_grew_since_acknowledgement, anddetail.warmup.acknowledgement_growthshows the numbers:
{
"detail": {
"code": "warmup_acknowledgement_required",
"message": "This WhatsApp number is still warming up and the campaign is large. ...",
"warmup": {
"requires_acknowledgement": true,
"reasons": ["...", "audience_grew_since_acknowledgement"],
"acknowledgement_growth": {
"acknowledged_audience_size": 1000,
"current_audience_size": 1600,
"growth_pct": 60.0,
"acknowledged_at": "2026-09-20T09:00:00Z"
},
"...": "same shape as the warmup-check response"
}
}
}
- A scheduled campaign is not sent at its send time: it is held
pausedwithwarmup_assessment.hold.kindaudience_grew(see Resume after a warm-up hold).
Confirm again with {"acknowledge_warmup_warning": true}; this stores the new audience size. Smaller growth never triggers a new warning.
Split sending
Set send_split_config when you create or update a campaign (PUT /api/v1/campaigns/{campaign_id}). It can be changed while the campaign is draft, approved or scheduled. Send "send_split_config": null to remove it again; leaving the field out keeps the saved value. An approved campaign accepts an update that only changes send_split_config.
{ "mode": "ramp", "daily_batches": [250, 1000, 5000], "batch_size": null, "send_time_local": "09:00" }
{ "mode": "fixed", "daily_batches": null, "batch_size": 500, "send_time_local": null }
| Field | Description |
|---|---|
mode | ramp (growing daily batches) or fixed (the same size every day). |
daily_batches | ramp only. Recipients per day, day 1 first. The last value repeats until everyone is reached. |
batch_size | fixed only. Recipients per day (at least 1). |
send_time_local | Optional HH:MM. Days 2 and later start at this time in the campaign's timezone (UTC if none is set). If omitted, each day starts at the same time of day as the first batch. |
Batches are counted per WhatsApp number, because Meta's limits apply per number. Recipients on other channels of the same campaign all get the message on day 1. A plan may cover at most 60 days; a longer plan is rejected with invalid_split_config.
The actual number of days depends on how many recipients each number reaches, so it is checked again against the campaign's real audience when you send it, schedule it (including POST /campaigns with scheduled_at), or resume one that was never prepared. If the plan would run too long, the request returns 422 with detail = {"code": "split_plan_too_long", "message": ...} and nothing is sent or scheduled. Use larger daily batches or split the audience across campaigns.
Validation errors return 422 with detail = {"code": ..., "message": ...}:
detail.code | Meaning |
|---|---|
invalid_split_config | The config shape or values are invalid (see message). |
split_sending_requires_whatsapp | The campaign has no WhatsApp channel. |
split_config_not_editable | The campaign is already sending or finished. |
While a split send runs, the campaign shows its progress in send_split_state:
{
"plan": [250, 1000, 5000, 5000],
"days_total": 4,
"current_day": 1,
"next_batch_at": "2026-09-24T07:00:00+00:00",
"day_started_at": "2026-09-23T07:00:01+00:00",
"timezone": "Europe/Berlin"
}
When a day's batch is done, the campaign waits in status throttled with throttle_reason daily_batch_limit and continues on its own at the next batch time. The campaign is completed once every day's batch has been sent. You can pause and resume it between days.
Send speed and batches
Two campaign settings, available on every channel and on email campaigns (same endpoints under /api/v1/email-campaigns/{id}):
send_rate_limit– messages per minute, 1–60000. The campaign never sends faster than this or than its channels allow.send_batch_size– recipients per batch. After each batch the campaign ispausedwiththrottle_reasonbatch_completeuntil you send the next batch.
Set them on create or PUT /api/v1/campaigns/{campaign_id}. Unlike send_split_config they can still be changed while the campaign is sending, throttled, paused or failed (an update that changes only these two fields); a change applies to the rest of the send. A finished campaign returns 422 send_pacing_not_editable. They are mutually exclusive with send_split_config.
When batches are active, POST /send returns an extra pacing object:
{ "pacing": { "paced": true, "batch_number": 1, "armed": 500, "remaining": 1950 } }
Send the next batch – POST /api/v1/campaigns/{campaign_id}/send-next-batch (campaigns:send). Optional body {"batch_size": 1000} (sends 1000 now and saves it as the batch size) or {"all_remaining": true}. Recipients who opted out, unsubscribed or were blocked in the meantime are skipped.
{ "batch_number": 2, "armed": 1000, "skipped_ineligible": 4, "remaining": 946, "campaign": { "status": "sending" } }
Only a campaign paused with batch_complete accepts it; otherwise (and for the losing request of two concurrent calls) the response is 409 {"code": "campaign_not_awaiting_batch", ...}. POST /resume on such a campaign sends the next batch at the saved size, or everyone left when no batch size is saved. POST /cancel stops the unsent batches.
List batches – GET /api/v1/campaigns/{campaign_id}/batches?page=1&page_size=50 (campaigns:read) returns the usual items + pagination, one item per batch (batch_number, total, pending, sent, delivered, read, failed, skipped), plus current_batch_number, send_batch_size and remaining.
Preview the plan – GET /api/v1/campaigns/{campaign_id}/send-plan (campaigns:read), optionally with send_rate_limit and send_batch_size query values to preview a change. It returns effective_rate_per_minute (your speed capped by the channels), rate_limited_by (campaign or channel), channels[] (channel_type, channel_id, recipients, remaining, cap_per_second), batches, eta_seconds (time between batches not included) and eta_seconds_per_batch. pacing_available: false means the feature is not available for your account yet: a saved send_rate_limit is kept but not applied, and send_batch_size is rejected with send_pacing_unavailable.
Validation errors return 422 with detail = {"code": ..., "message": ...}:
detail.code | Meaning |
|---|---|
send_rate_limit_out_of_range | Not a whole number from 1 to 60000. |
send_batch_size_invalid | Not a whole number of at least 1. |
send_pacing_conflicts_with_split | Combined with send_split_config. |
send_pacing_unavailable | Batches are not available for your account yet. |
send_pacing_not_editable | The campaign is finished. |
invalid_send_pacing | Any other invalid value. |
Get Campaign Statistics
Retrieve delivery metrics for a campaign. Requires the campaigns:read scope.
GET /api/v1/campaigns/{campaign_id}/statistics
curl -X GET "https://api.sendseven.com/api/v1/campaigns/c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8/statistics" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json"
{
"campaign_id": "c3d4e5f6-7a8b-49c0-d1e2-f3a4b5c6d7e8",
"campaign_name": "Weekend Sale Reminder",
"status": "sent",
"total_recipients": 2450,
"sent_count": 2450,
"delivered_count": 2398,
"read_count": 1120,
"failed_count": 52,
"pending_count": 0,
"skipped_count": 0,
"status_counts": { "sent": 2450, "delivered": 2398, "read": 1120, "failed": 52 },
"started_at": "2026-02-15T08:00:05Z",
"completed_at": "2026-02-15T08:14:32Z",
"delivery_warning": null
}
Delivery warning: no updates from WhatsApp
GET /api/v1/campaigns/{campaign_id} and GET /api/v1/campaigns/{campaign_id}/statistics both return delivery_warning. It is null unless a WhatsApp campaign got no delivery updates:
"delivery_warning": {
"code": "no_delivery_updates",
"channel_id": "b2c3d4e5-6f7a-48b9-c0d1-e2f3a4b5c6d7",
"first_sent_at": "2026-02-15T08:00:07Z"
}
| Field | Description |
|---|---|
code | Always no_delivery_updates for now. Treat unknown codes as a generic warning. |
channel_id | The WhatsApp channel whose messages got no updates. If several channels qualify, the one with the most sent messages. |
first_sent_at | UTC time the first message on that channel was accepted by WhatsApp. |
The warning is set when, for one WhatsApp channel of the campaign:
- at least one message was accepted by WhatsApp (sent),
- none of those messages has since been reported as delivered, read or failed, and
- the first send is more than 1 hour old (5 or more messages sent) or more than 6 hours old (fewer than 5 sent, so a couple of offline recipients do not trigger it).
WhatsApp reports delivered, read and failed only through status webhooks, so this pattern almost always means WhatsApp stopped sending updates for that number, typically because the connection was revoked or lost its permissions. Reconnect the channel (for numbers that also use the WhatsApp Business app, through the coexistence connection flow). Updates for messages sent while the connection was broken are not sent again, so their status stays sent. Sends rejected by WhatsApp at send time do not count as updates.
The list endpoint (GET /api/v1/campaigns) does not compute the warning; delivery_warning is always null there.
Messaging campaigns track delivery stats (sent/delivered/read/failed). Open-rate, click-rate, and bounce metrics are specific to Email Campaigns, which expose them via GET /api/v1/email-campaigns/{id}/analytics.
List Campaigns
GET /api/v1/campaigns
Returns a plain JSON array of campaigns (not a paginated items/pagination envelope). Paginate with limit (default 50) and offset, and optionally filter by campaign_status.
curl -X GET "https://api.sendseven.com/api/v1/campaigns?limit=20&offset=0&campaign_status=sent" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00"
Python Example
import requests
BASE_URL = "https://api.sendseven.com/api/v1"
HEADERS = {
"Authorization": "Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00",
"Content-Type": "application/json",
}
# Create a draft campaign (Telegram)
campaign = requests.post(
f"{BASE_URL}/campaigns",
headers=HEADERS,
json={
"name": "Flash Sale Alert",
"list_ids": ["list_active_customers"],
"channel_content": {
"telegram": {
"message_type": "text",
"message_text": "24-hour flash sale! 50% off everything. Shop now: shop.example.com",
}
},
},
).json()
print(f"Created: {campaign['id']} ({campaign['total_recipients']} recipients)")
# Send it immediately
result = requests.post(f"{BASE_URL}/campaigns/{campaign['id']}/send", headers=HEADERS).json()
print(result["message"])
# Check statistics later
stats = requests.get(
f"{BASE_URL}/campaigns/{campaign['id']}/statistics",
headers=HEADERS,
).json()
print(f"Delivered: {stats['delivered_count']}/{stats['sent_count']}, read: {stats['read_count']}")
JavaScript Example
const BASE_URL = "https://api.sendseven.com/api/v1";
const HEADERS = {
"Authorization": "Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00",
"Content-Type": "application/json",
};
// Create a draft campaign
const createRes = await fetch(`${BASE_URL}/campaigns`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: "Valentine's Day Special",
list_ids: ["list_newsletter"],
channel_content: {
telegram: {
message_type: "text",
message_text: "Show someone you care with our Valentine's collection. Order by Feb 12!",
},
},
}),
});
const campaign = await createRes.json();
// Schedule it for a future time (local datetime + IANA timezone)
await fetch(`${BASE_URL}/campaigns/${campaign.id}/schedule`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ scheduled_at: "2026-02-12T08:00:00", timezone: "Europe/Berlin" }),
});
console.log(`Campaign ${campaign.id} scheduled.`);
Error Responses
Most errors return detail as a message string. The cases with a machine-readable code return {"detail": {"code": "...", "message": "..."}}; the table lists the code. See Error Handling for the response shapes.
| Status | detail / detail.code | Description |
|---|---|---|
| 400 | "Cannot update campaign that is sending or completed", "Cannot delete campaign that is sending or completed" | The campaign can no longer be changed |
| 401 | "Invalid or expired API token" | Token is invalid or expired |
| 403 | "Missing required permission(s): ..." | Token lacks the required campaigns:* scope |
| 404 | "Campaign not found" | Unknown campaign ID, or it belongs to another workspace |
| 404 | "Attachment {id} not found" | Create/update references an attachment that is not in your workspace (details) |
| 404 | list_not_found / channel_not_found / contact_not_found | A list, channel or contact in a preview request does not belong to your workspace (details) |
| 409 | campaign_send_in_progress | Another send, resume or scheduled start of this campaign is already running |
| 409 | warmup_acknowledgement_required | Large WhatsApp send during number warm-up; retry with acknowledge_warmup_warning: true or use split sending (details) |
| 422 | list of field errors | Invalid request body |
| 422 | invalid_split_config / split_sending_requires_whatsapp / split_config_not_editable | send_split_config was rejected (details) |
| 409 | campaign_not_awaiting_batch | send-next-batch on a campaign that is not waiting for its next batch (details) |
| 422 | send_rate_limit_out_of_range / send_batch_size_invalid / send_pacing_conflicts_with_split / send_pacing_unavailable / send_pacing_not_editable / invalid_send_pacing | Send speed or batch size was rejected (details) |
| 422 | split_plan_too_long | The split plan would take too many days for the campaign's audience (details) |
Next Steps
- Email Campaigns -- HTML email campaigns with templates
- Tags & Lists -- manage recipient lists
- Manage Contacts -- import and organize contacts