Skip to main content

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.

Who receives a marketing template

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:

typeWhy
audience_emptyNobody in the audience can receive it
broadcast_limit_reachedThe audience exceeds the recipient cap (limit says which)
quota_exceededThe 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}/recipients and /export — each recipient, with status and error.
  • The broadcast.paused and broadcast.completed events 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.