Bulk iMessage API: How to Send Campaigns Programmatically
Apple does not publish a bulk iMessage API — there is no server-side Apple endpoint for sending iMessages at scale. Bulk iMessage delivery works through messaging platforms that operate real Apple hardware and expose it behind a REST interface. This guide covers how to send bulk messages through the senderZ campaigns endpoint: targeting recipients with explicit lists or contact groups, E.164 validation, per-campaign size limits, and the compliance rails that run on every send.
One thing to settle first, because it decides what "bulk iMessage" can honestly mean.
Why There Is No Official Bulk iMessage API
Apple's only sanctioned business messaging channel is Messages for Business, and it is consumer-initiated by design: a customer starts the conversation from Maps, Safari, or Spotlight, and the business replies. There is no outbound path. You cannot use it to message a list of phone numbers, which rules it out for campaigns entirely.
iMessage also rides Apple's own encrypted network rather than the carrier SMS system. That has two consequences for bulk sending. First, there is no carrier gateway to register with — no 10DLC brand vetting, no campaign registry, no per-message carrier surcharges. Second, there is no aggregator you can buy throughput from. The only way a message becomes a blue bubble is for it to originate from genuine Apple hardware signed into the Apple ecosystem.
That is the architecture behind every working bulk iMessage API: a fleet of real devices with real phone numbers, fronted by an HTTP API. senderZ runs this as a managed pipeline — your API call creates a campaign, each message is queued individually, and a routing layer picks a sending number and delivers over a proprietary iMessage bridge running on dedicated Apple hardware.
Bulk iMessage to strangers does not exist
Every platform advertising "bulk iMessage" is really advertising a countdown.
Apple suspends accounts that behave like cold outreach, and the trigger is not volume — it is the junk report. A handful of "Report Junk" taps can end an account within hours, and that button is only ever one tap away from someone who has never talked to you. Blast a purchased list of blue bubbles and the channel is gone, usually before the campaign finishes.
So senderZ draws a hard line: an iMessage line only messages people who have messaged it first. Campaign messages to anyone else go out as SMS instead — same number, same request, no split lists.
In practice that reshapes bulk work rather than blocking it:
- Recipients who have texted you before get iMessage, and there is no cap on how often you message them.
- Everyone else gets SMS, which is what SMS is legally built for and what your plan's monthly allowance counts.
- Anyone who scans your QR code or taps your Text Us link starts the conversation themselves — which costs nothing against that allowance and moves them permanently onto iMessage.
That last point is the whole strategy. The cheapest and most durable way to grow a blue-bubble audience is to let people opt into it, not to buy a list.
Every campaign message still goes out with channel auto, so you never split
your list by device type — senderZ picks the right rail per recipient. The
mechanics of per-recipient detection are covered in how to send iMessage from an API.
Send Bulk iMessages with POST /v1/campaigns
A campaign is one request: a message body (or template) plus an audience. senderZ fans it out to every recipient, applies compliance checks per message, and tracks aggregate progress under a single campaign ID. If you have not set up an account yet, the quickstart guide walks through creating an API key in a few minutes.
The endpoint accepts these fields:
| Field | Type | Required | Notes |
|---|---|---|---|
| body | string | one of body/template | Message text, max 5,000 characters |
| template | string | one of body/template | Name of an active template; unknown or inactive names return 404 INVALID_TEMPLATE |
| recipients | string[] | optional | Explicit E.164 phone number list; deduplicated automatically |
| group_id | string | optional | Contact group to target; "__all__" or omitted means every contact |
| scheduled_at | string | optional | ISO-8601 timestamp; omit to send now |
Here is a send to an explicit recipient list with curl:
curl -X POST https://api.senderz.com/v1/campaigns \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"recipients": ["+15555550101", "+15555550102", "+15555550103"],
"body": "Doors open at 6 tonight. First 50 people get a free tasting flight. Reply STOP to opt out."
}'
The API responds with 201 Created as soon as the campaign record exists — dispatch continues in the background so large lists do not block the response:
{
"campaign_id": "01J8ZQ4T9R2K7M3N5P6QWERTYX",
"status": "sending",
"recipient_count": 3,
"created_at": "2026-07-15T17:02:11.000Z"
}
The same call in TypeScript, with error handling for the { error, code } shape senderZ returns on failures:
type CampaignResponse = {
campaign_id: string
status: 'sending' | 'scheduled'
recipient_count: number
created_at: string
}
type ApiError = {
error: string
code: string
}
async function sendCampaign(recipients: string[], body: string): Promise<CampaignResponse> {
const res = await fetch('https://api.senderz.com/v1/campaigns', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SENDERZ_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipients, body }),
})
if (!res.ok) {
const err = (await res.json()) as ApiError
throw new Error(`Campaign failed [${err.code}]: ${err.error}`)
}
return (await res.json()) as CampaignResponse
}
const campaign = await sendCampaign(
['+15555550101', '+15555550102'],
'Your July order window closes Friday. Reply STOP to opt out.'
)
console.log(campaign.campaign_id, campaign.status)
Campaigns require an active subscription or trial. If a 14-day trial has expired without a plan, the API blocks the send up front with a TRIAL_EXPIRED error rather than letting a campaign go out unbilled — better than discovering it halfway through a list.
Recipient Lists vs Contact Groups for Bulk Campaigns
There are three ways to define a campaign audience, and they resolve in a strict priority order.
Explicit recipient lists. Pass recipients as an array of E.164 phone numbers. This is the right fit when your source of truth lives outside senderZ — a CRM export, a database query, a spreadsheet. The list is deduplicated automatically, so a number appearing twice gets one message. When recipients is present, it wins: any group_id in the same request is ignored.
Contact groups. Pass group_id referencing a group in your senderZ contact book. Groups suit recurring audiences — "VIP customers", "July signups" — because you maintain membership once and every campaign targeting the group picks up the current members at send time:
{
"group_id": "01J8ZQGV7W2X4Y6Z8A9BCDEFGH",
"template": "july-promo"
}
All contacts. Omit both recipients and group_id (or pass group_id: "__all__") and the campaign targets every contact in your book. Useful for account-wide announcements; use it deliberately.
Whichever path you choose, one subtraction always happens before anything is queued: recipients with an active opt-out are removed from the audience. If someone texted STOP last month, they are not in this campaign — no code required on your side. If the audience resolves to zero recipients after that filter, the API returns a VALIDATION_ERROR instead of creating an empty campaign.
E.164 Validation and Bulk Send Limits
Bad phone numbers are the silent killer of bulk sends. A malformed number that slips into dispatch surfaces later as a per-message failure — invisible in the create response, annoying to reconcile afterwards. senderZ validates explicit recipient lists at create time instead, against the E.164 pattern +[country code][number] (regex: ^\+[1-9]\d{6,14}$). Any invalid entry rejects the whole request before a single message is queued:
{
"error": "2 recipient(s) are not valid E.164 phone numbers (e.g. +14155552671): 555-0101, (555) 555-0102",
"code": "VALIDATION_ERROR"
}
The error names the count and the first offenders, so you can fix your list in one pass. Normalize numbers before calling the API: strip formatting characters, add the + prefix and country code (+1 for US numbers), and drop anything that does not survive normalization. A small helper covers the common US cases:
function normalizeUsNumber(raw: string): string | null {
// Keep digits only: "(555) 555-0102" -> "5555550102"
const digits = raw.replace(/\D/g, '')
// 10 digits: assume US, prepend country code
if (digits.length === 10) return `+1${digits}`
// 11 digits starting with 1: already has the US country code
if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`
// Anything else: do not guess — surface it for manual review
return null
}
const raw = ['(555) 555-0101', '555-555-0102', '+15555550103', '12']
const normalized: string[] = []
const rejected: string[] = []
for (const entry of raw) {
const e164 = normalizeUsNumber(entry)
if (e164) normalized.push(e164)
else rejected.push(entry)
}
// normalized -> ['+15555550101', '+15555550102', '+15555550103']
// rejected -> ['12'] (log these; never guess a country code)
Rejecting ambiguous input locally beats letting a guessed country code send someone in another country a marketing message at 3 AM their time.
Two size limits apply per campaign:
- Message body: 5,000 characters maximum. Longer bodies are rejected with a
VALIDATION_ERROR. - Recipient count: scales with message size. senderZ enqueues campaign messages in batches — Cloudflare Queues caps a single batch at 100 messages or 256 KB, whichever binds first — and budgets a fixed number of batches per campaign. For a short message that works out to 40,000 recipients per campaign; longer bodies shrink the ceiling because fewer messages fit per batch.
If your list exceeds the ceiling, the API tells you the exact limit for your message size:
{
"error": "Recipient list too large (52000). Max 40000 per campaign for this message size — split into smaller campaigns.",
"code": "VALIDATION_ERROR"
}
Splitting is straightforward: chunk your recipient array and create one campaign per chunk. Each returns its own campaign_id for tracking:
async function sendInChunks(
recipients: string[],
body: string,
chunkSize = 40_000
): Promise<string[]> {
const campaignIds: string[] = []
for (let i = 0; i < recipients.length; i += chunkSize) {
const chunk = recipients.slice(i, i + chunkSize)
const campaign = await sendCampaign(chunk, body) // from the earlier example
campaignIds.push(campaign.campaign_id)
}
return campaignIds
}
// 52,000 recipients -> two campaigns: 40,000 + 12,000
const ids = await sendInChunks(bigList, 'Your July order window closes Friday. Reply STOP to opt out.')
In practice the recipient ceiling is rarely the first limit you meet — plan-level new-contact caps (covered next) shape how fast a list that size becomes reachable in the first place.
Compliance Rails Built Into Every Bulk Send
Every message dispatched from a campaign is classified as marketing traffic, which gets the strictest compliance treatment senderZ has. These checks run in the delivery pipeline per message — after your API call succeeds and before anything reaches a recipient.
Opt-out enforcement. The TCPA requires honoring opt-out requests, and statutory damages run $500 to $1,500 per message for violations — multiplied across a bulk list, that is business-ending math. senderZ processes STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, and QUIT on every inbound message and records the opt-out immediately; START, UNSTOP, or YES opts the person back in. Opted-out numbers are excluded when the campaign is created and re-checked at delivery time. On shared sending numbers, a STOP is also honored line-wide, so a recipient who opted out never hears from that number again regardless of which account triggered the send. For the full regulatory picture, see the SMS compliance guide for developers.
Quiet hours. Marketing messages are not delivered between 8 PM and 8 AM in the recipient's local time, inferred from their area code. A campaign created at 11 PM Eastern does not blast sleeping customers — affected messages are rescheduled to the morning window and delivered then, not dropped. Your campaign completes; the timing shifts.
First-contact limits. senderZ plans meter first-contacts — reaching a number that has never texted you, which goes over SMS — rather than message volume: 10 per day on Starter, 50 on Growth, 500 on Scale. Messaging anyone who has texted you is unlimited on every plan. Fresh sending numbers also ramp up gradually to protect deliverability. Plan details are on the pricing page. For bulk work the practical guidance is: grow your reachable audience steadily instead of importing 10,000 cold numbers and expecting day-one delivery — pacing is what keeps the channel healthy for everyone sending from it.
None of this requires code on your side. The rails exist so a bulk send cannot accidentally become a compliance incident.
Scheduling and Tracking Bulk iMessage Campaigns
Pass scheduled_at with an ISO-8601 timestamp to queue a campaign for later:
{
"group_id": "01J8ZQGV7W2X4Y6Z8A9BCDEFGH",
"body": "Reminder: your tasting reservation is tomorrow at 7 PM. Reply STOP to opt out.",
"scheduled_at": "2026-07-16T17:00:00Z"
}
The campaign is created with status: "scheduled" and a scheduler dispatches it when due. Two details worth knowing:
- The timestamp is validated up front. A malformed
scheduled_atreturns400 VALIDATION_ERRORat create time instead of producing a campaign that silently never fires. - The audience is re-resolved at send time. Opt-outs received between scheduling and dispatch are honored — someone who texts STOP on Tuesday is not in Wednesday's scheduled send, even though they were in the audience when you created it.
Once a campaign exists, poll its status:
curl https://api.senderz.com/v1/campaigns/01J8ZQ4T9R2K7M3N5P6QWERTYX \
-H "Authorization: Bearer YOUR_API_KEY"
{
"id": "01J8ZQ4T9R2K7M3N5P6QWERTYX",
"tenant_id": "01J8ZP2M4N6P8Q1R3S5T7V9WXY",
"name": null,
"group_id": null,
"body": "Doors open at 6 tonight. First 50 people get a free tasting flight. Reply STOP to opt out.",
"status": "completed",
"total_recipients": 3,
"sent_count": 3,
"failed_count": 0,
"scheduled_at": null,
"started_at": "2026-07-15T17:02:11.000Z",
"completed_at": "2026-07-15T17:02:12.000Z",
"created_at": "2026-07-15T17:02:11.000Z"
}
The status lifecycle is scheduled → sending → completed, with failed reserved for dispatch errors — and a failed campaign records how many messages went out before the failure rather than sticking forever in a sending state. A minimal polling loop that waits for a campaign to settle:
type CampaignStatus = {
id: string
status: 'scheduled' | 'sending' | 'completed' | 'failed'
total_recipients: number
sent_count: number
failed_count: number
}
async function waitForCampaign(campaignId: string, timeoutMs = 60_000): Promise<CampaignStatus> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetch(`https://api.senderz.com/v1/campaigns/${campaignId}`, {
headers: { 'Authorization': `Bearer ${process.env.SENDERZ_API_KEY}` },
})
const campaign = (await res.json()) as CampaignStatus
if (campaign.status === 'completed' || campaign.status === 'failed') {
return campaign
}
await new Promise((r) => setTimeout(r, 2_000))
}
throw new Error(`Campaign ${campaignId} still dispatching after ${timeoutMs}ms`)
}
Note that sent_count counts messages handed to the delivery pipeline; per-message delivery outcomes resolve afterwards, as each message is routed and delivered. For those, poll GET /v1/messages — or skip polling and register a webhook so senderZ pushes each outcome to your server:
curl -X POST https://api.senderz.com/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/senderz/events",
"events": ["message.delivered", "message.failed"]
}'
Five event types are available — message.queued, message.sent, message.delivered, message.failed, and message.received — and for campaign work the delivered/failed pair is usually what you want: it gives you a per-recipient outcome log without polling thousands of message IDs.
GET /v1/campaigns lists your 50 newest campaigns in the standard { data: [...] } envelope, which is enough for a campaign history screen without extra bookkeeping on your side. Full request and response details for every campaign endpoint are in the campaigns API reference.
FAQ
Frequently asked questions
Is there an official Apple API for bulk iMessage?
No. Apple's only business messaging channel is Messages for Business, which customers must initiate — there is no outbound or bulk path. Bulk iMessage sending works through platforms like senderZ that operate real Apple hardware and expose a REST API in front of it.
How many recipients can one campaign include?
The ceiling scales with message size because messages are enqueued in size-capped batches. A short message allows 40,000 recipients per campaign; if your list exceeds the limit, the validation error states the exact maximum for your message size so you can split the list.
Do bulk iMessages fall back to SMS automatically?
Yes. Every campaign message is sent with channel auto: recipients registered with iMessage get a blue-bubble message, and everyone else receives the same text as SMS. You do not need to segment your list by device type.
How are opt-outs handled in bulk sends?
Recipients with an active opt-out are removed when the campaign is created, and the check runs again at delivery time — including for scheduled campaigns. STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, and QUIT are all processed on inbound messages automatically.
Can I schedule a bulk iMessage campaign for later?
Yes. Pass scheduled_at as an ISO-8601 timestamp and the campaign is created in a scheduled state, then dispatched when due. The audience is re-resolved at send time, so opt-outs received after scheduling are still honored.
Ready to send your first bulk iMessage campaign? Start a 14-day free trial — no credit card required — and follow the quickstart guide to get an API key and send a campaign in one sitting. Opt-outs, quiet hours, and delivery tracking are already handled.