Meta-approved template
Free-form text is not allowed outside the 24-hour window. Submit and approve in Meta Business Manager before you write any code.
A “broadcast” in this guide means: same template, many recipients, sent over minutes-to-hours, surviving partial failures and process restarts. Marketing promos, app-update announcements, scheduled reminders — anything fan-out from one event to N customers.
The recipe is built on three primitives kirim.dev already gives you:
message.status webhooks for delivery tracking at scale (you do
not want to poll N message ids).Meta-approved template
Free-form text is not allowed outside the 24-hour window. Submit and approve in Meta Business Manager before you write any code.
Respect your messaging tier
Tiers are per phone_number_id: 250, 1k, 10k, 100k, or unlimited
unique recipients per 24h. Exceed it and Meta returns
quota_exceeded until the window resets.
Deterministic idempotency keys
sha256(campaign_id + ":" + phone). Retries, replays, and restarts
converge on a single send per recipient.
Opt-in only
Meta penalises unsolicited broadcasts — low quality ratings cap your tier or block your number. Only message numbers who explicitly consented.
Approve the template + warm up your tier.
Submit the template (e.g. promo_flash_sale) in Meta Business
Manager. New numbers start on the 250 unique recipients / 24h
tier. To reach 1k / 10k / 100k, you need a steadily rising
quality rating from real-world traffic — broadcasting cold to 50k
contacts on day one will fail at recipient 251 with
quota_exceeded.
Resolve phone_number_id and choose your sender.
The tier is per number. If you have multiple connected numbers, pick the one with the highest tier for the campaign:
const accounts = await kirim.accounts.list().page()const sender = accounts.data .filter((a) => a.status === 'connected') .sort((a, b) => b.messaging_tier - a.messaging_tier)[0]
const PHONE_NUMBER_ID = sender.phone_number_id // '106540352242922'Generate a campaign_id.
Any UUID will do — it scopes the idempotency keys so two different campaigns to overlapping recipients don’t collide:
const campaignId = crypto.randomUUID()// -> e.g. "c1f0e8b2-7d3a-4f6e-9a2b-3c4d5e6f7a8b"Persist it before the run starts. If your worker crashes, restart
with the same campaign_id to resume; the idempotent sends
will skip recipients who already received.
Load the recipient list.
From your CRM, a CSV, or a Postgres table. Every recipient must have explicit opt-in recorded. We assume a shape like:
type Recipient = { phone: string // E.164, e.g. '+628111222333' first_name: string opted_in_at: Date}Send each template with a deterministic key.
The send call is identical to a one-off template send (see Order notifications). The difference at scale is the key recipe and the concurrency limiter.
Key: sha256(campaign_id + ":" + phone). This guarantees:
Control concurrency.
10 parallel sends sits comfortably under the default Kirim rate limit (see Rate limits). Higher concurrency risks 429s; lower wastes throughput. Tune per tier:
| Tier | Suggested concurrency |
|---|---|
| 250 | 5 |
| 1k | 10 |
| 10k | 20 |
| 100k+ | 50 |
Persist results per recipient.
On success, log message_id + status queued. On error, log
error.code + error.message. You want a row per recipient so a
follow-up dashboard / re-run script has a complete picture.
Track delivery via message.status webhook.
Same handler as a one-off send — but keyed on message_id so you
can update the per-recipient row. Don’t poll
GET /v1/{phone_number_id}/messages/{id} for thousands of ids; the
webhook does that work for free.
import { Kirim, RateLimitError, InvalidRequestError } from '@kirimdev/sdk'import pLimit from 'p-limit'import { createHash, randomUUID } from 'node:crypto'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })const PHONE_NUMBER_ID = '106540352242922'
type Recipient = { phone: string; first_name: string }
async function runCampaign(recipients: Recipient[], campaignId = randomUUID()) { const phone = kirim.phoneNumbers(PHONE_NUMBER_ID) const limit = pLimit(10) // tune per tier const stats = { sent: 0, replayed: 0, failed: 0 }
await Promise.all( recipients.map((r) => limit(async () => { const key = createHash('sha256') .update(`${campaignId}:${r.phone}`) .digest('hex')
try { const msg = await phone.messages.send( { messaging_product: 'whatsapp', to: r.phone, type: 'template', template: { name: 'promo_flash_sale', language: { code: 'id_ID' }, components: [ { type: 'body', parameters: [{ type: 'text', text: r.first_name }], }, ], }, }, { idempotencyKey: key }, )
await db.campaign_sends.upsert({ campaign_id: campaignId, phone: r.phone, message_id: msg.id, status: msg.status, // 'queued' on first send, possibly cached on replay })
stats.sent++ } catch (err) { if (err instanceof RateLimitError) { // SDK already auto-retries with `Retry-After`; this only fires if it // ran out of retries. Re-throw and pause the campaign. throw err } if (err instanceof InvalidRequestError) { // Permanent failure (bad phone, template paused, etc.). await db.campaign_sends.upsert({ campaign_id: campaignId, phone: r.phone, status: 'failed', error_code: err.code, error_message: err.message, }) stats.failed++ return } throw err } }), ), )
return { campaignId, ...stats }}import asyncioimport hashlibimport osimport uuid
import httpx
KIRIM_KEY = os.environ["KIRIM_KEY"]PHONE_NUMBER_ID = "106540352242922"BASE = "https://api.kirim.dev/v1"
async def run_campaign(recipients: list[dict], campaign_id: str | None = None): campaign_id = campaign_id or str(uuid.uuid4()) sem = asyncio.Semaphore(10) # tune per tier
async with httpx.AsyncClient( base_url=BASE, headers={"Authorization": f"Bearer {KIRIM_KEY}"}, timeout=30.0, ) as client:
async def send_one(r): async with sem: key = hashlib.sha256( f"{campaign_id}:{r['phone']}".encode() ).hexdigest()
resp = await client.post( f"/{PHONE_NUMBER_ID}/messages", json={ "messaging_product": "whatsapp", "to": r["phone"], "type": "template", "template": { "name": "promo_flash_sale", "language": {"code": "id_ID"}, "components": [{ "type": "body", "parameters": [ {"type": "text", "text": r["first_name"]} ], }], }, }, headers={"Idempotency-Key": key}, )
if resp.status_code == 202: return ("sent", resp.json()["id"]) if resp.status_code == 422: return ("failed", resp.json().get("error", {}).get("code")) if resp.status_code == 429: retry_after = int(resp.headers.get("retry-after", "30")) await asyncio.sleep(retry_after) return await send_one(r) resp.raise_for_status()
return await asyncio.gather(*(send_one(r) for r in recipients))Add the same message.status webhook handler you’d use for a one-off
send (see Order notifications step
5–6). The only change at broadcast scale is the storage shape — you
want a row per recipient, not a row per send call:
app.post('/kirim-hook', async (c) => { const raw = await c.req.text() const ok = await verifyWebhookSignature( raw, c.req.header('x-kirim-signature'), process.env.KIRIM_WEBHOOK_SECRET!, ) if (!ok) return c.text('bad signature', 401)
const event = JSON.parse(raw) if (event.type !== 'message.status') return c.text('ok')
await db.campaign_sends.update({ where: { message_id: event.data.id }, data: { status: event.data.status, error_code: event.data.error?.code ?? null, delivered_at: event.data.status === 'delivered' ? new Date() : undefined, }, }) return c.text('ok')})A pseudo-SQL view to spot-check progress mid-run — adapt to whatever table you persisted results into:
SELECT status, COUNT(*) AS count, ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pctFROM campaign_sendsWHERE campaign_id = 'c1f0e8b2-7d3a-4f6e-9a2b-3c4d5e6f7a8b'GROUP BY statusORDER BY count DESC;Expected shape mid-flight:
| status | count | pct |
|---|---|---|
delivered | 7,432 | 74.3 |
sent | 1,801 | 18.0 |
queued | 612 | 6.1 |
failed | 155 | 1.6 |
When queued + sent reaches zero, the campaign is fully terminal — at
that point export failed rows, bucket by error_code, and decide
whether any are worth retrying (e.g. whatsapp_upstream_error —
re-run the campaign with the same id; recipient_not_on_whatsapp —
remove from list permanently).
Rate limits & tiers
Kirim’s org-wide limits, headers, and Retry-After behaviour. →
/concepts/rate-limits/
Idempotency deep-dive
The 24h key window, replay semantics, conflict handling. → /concepts/idempotency/
Failed sends
Stable error codes, retry-vs-give-up decision matrix. → /guides/failed-sends/
Webhook signing
Verify the delivery callbacks so nobody fakes “delivered” status. → /webhooks/signing/