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 unified per-unit rate as conversation messages and KB queries, and draw from the same monthly included pool 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 |
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_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.
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 |
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 |
POST /campaigns/preview/recipients | campaigns:read and contacts:read |
POST /campaigns/preview | campaigns:read and contacts:read |
POST /campaigns/preview/variable-stats | campaigns:read |
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.
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.
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).
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 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.
{
"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. The campaign returns to sending; recipients that were still pending (for example the unsent tail of a paused campaign) are queued together with them, so a single call resumes the whole campaign. A 400 is returned for campaigns in any other status.
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 |
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. 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.
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"
}
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
| Status | Error Code | Description |
|---|---|---|
| 401 | INVALID_TOKEN | Token is invalid or expired |
| 403 | INSUFFICIENT_SCOPE | Token lacks required campaigns scope |
| 404 | RESOURCE_NOT_FOUND | Campaign not found |
| 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 | CONFLICT | Cannot modify a campaign that is already sending or sent |
| 409 | warmup_acknowledgement_required | Large WhatsApp send during number warm-up; retry with acknowledge_warmup_warning: true or use split sending (details) |
| 422 | VALIDATION_ERROR | Invalid request (e.g., empty list_ids) |
| 422 | invalid_split_config / split_sending_requires_whatsapp / split_config_not_editable | send_split_config 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