Campaigns
A campaign sends an approved template to an audience: every contact a filter selects, with each one's variables filled in. Through the API you build, check, launch and follow the campaign — the sending itself runs in a separate process, at the pace you set, without spending your key's sending limit.
A campaign's path
materializing is the building of the recipient list: the audience is frozen at launch,
and whoever joins the base afterwards does not receive it. A campaign can also end as
canceled or, when nobody in the audience can receive it, as failed.
1. Create the draft
curl -X POST https://api.joinotify.com/broadcasts \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{
"name": "Black Friday",
"phoneNumberId": "106540352242922",
"templateName": "promo_black_friday",
"templateLanguage": "en_US",
"audienceId": "cm1aud1ence01",
"variables": {
"body": {
"1": { "source": "contact", "key": "first_name", "fallback": "there" },
"2": { "source": "static", "value": "BLACK20" }
}
},
"sendWindow": { "days": [1, 2, 3, 4, 5], "start": "09:00", "end": "20:00" },
"timezone": "America/New_York"
}'
Only name is required; the rest can come later, through PATCH. Nothing is checked on
save — checking happens at the estimate, the test and the launch.
Variables. Each template placeholder needs a source: contact (a contact detail,
such as first_name), attribute (a custom field) or static (a fixed value). The first
two require a fallback for whoever lacks the data. A template with a media header needs
headerMedia — upload the file with POST /broadcasts/media and use the id.
Audience. A saved audience (audienceId) or a filter for this campaign only
(audienceFilter), with the same condition tree. Create reusable audiences with
POST /audiences and check who they select with POST /audiences/preview.
2. Estimate and test
POST /broadcasts/{id}/estimate checks the whole campaign and answers who it would reach
right now:
{
"data": {
"recipients": 4800,
"total": 5230,
"skipped": { "opted_out": 120, "no_consent": 290, "suppressed": 20 },
"cap": 50000,
"firstCampaign": false,
"overCap": 0,
"remainingQuota": 18000,
"portfolioLimit": 10000,
"estimatedCost": { "micros": 297600000, "currency": "usd", "byCountry": [], "unpriced": 0 }
}
}
Then send the campaign to yourself with POST /broadcasts/{id}/test and
{ "to": "+5541987111527" }. The test goes out right away, outside the metrics, and shows
the variables filled with the fallback values.
Only contacts with consent (opted_in) and off the suppression list. Utility templates do
not depend on consent, but still skip invalid numbers. See
Contacts and consent.
3. Launch
curl -X POST https://api.joinotify.com/broadcasts/cm1bro4dc4st01/launch \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'Content-Type: application/json' \
-d '{ "scheduledAt": "2026-11-27T12:00:00-05:00" }'
Without scheduledAt, the campaign starts at once. The answer is 202. The launch refuses
with 422:
type | Why |
|---|---|
audience_empty | Nobody in the audience can receive it |
broadcast_limit_reached | The audience exceeds the recipient cap (limit says which) |
quota_exceeded | The plan has no messages left this month |
The cap grows with the history. The account's first campaign goes to up to 1,000 people; the next ones, to up to ten times the largest campaign already completed — always within the plan limit. That is what protects a new number's quality from an oversized send.
Editing a scheduled campaign turns it back into a draft: it has to be launched again.
4. Follow
GET /broadcasts/{id}/stats— counters, rates, clicks per button, grouped failures, cost per country and the number's quality before and now.GET /broadcasts/{id}/timeseries— the progress in 15-minute buckets.GET /broadcasts/{id}/recipientsand/export— each recipient, with status and error.- The
broadcast.pausedandbroadcast.completedevents on your webhook.
Automatic pauses
A campaign stops on its own when carrying on would harm the number: red quality, template
paused by Meta, restricted account, plan quota used up, too many errors in a row. The
reason goes in pauseReason and arrives in the broadcast.paused event — the full table
is in the event catalog.
Two pauses sort themselves out and carry resumeAfter: send_window (outside the send
window) and throughput (Meta held back the throughput; it retries after 15 minutes, 1
hour and 4 hours). The others wait for someone to fix the cause and call
POST /broadcasts/{id}/resume.
Sends with no answer
When Meta does not answer a send, the recipient becomes unknown. It is not retried
on its own: the message may have arrived, and retrying would send it twice. If you decide
to resend, POST /broadcasts/{id}/resend-unknown with { "confirm": true } puts those
recipients back in the queue.
Key scope
An API key limited to some numbers only sees and creates campaigns on those numbers.
Saved audiences belong to the whole account: /audiences answers 403 for it.
The audience of a campaign estimated or launched with that key — saved or typed as a filter —
is narrowed to the contacts that have a conversation on its numbers, and the slice is frozen at
launch, together with the audience. In a test send, a contactId outside that slice answers
404. A campaign launched with an unrestricted key, or from the dashboard, goes to the whole base.