Skip to content
Recipes

Run a Template Broadcast Campaign

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:

  • Template messages for sending outside the 24-hour window.
  • Idempotency keys so a crashed worker resuming mid-batch never double-sends.
  • 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.

  1. 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.

  2. 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'
  3. 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.

  4. 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
    }
  5. 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:

    • Two runs of the same campaign collapse to one send per recipient.
    • Two different campaigns to the same recipient produce different keys and both proceed.
  6. 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:

    TierSuggested concurrency
    2505
    1k10
    10k20
    100k+50
  7. 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.

  8. 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 }
}

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 pct
FROM campaign_sends
WHERE campaign_id = 'c1f0e8b2-7d3a-4f6e-9a2b-3c4d5e6f7a8b'
GROUP BY status
ORDER BY count DESC;

Expected shape mid-flight:

statuscountpct
delivered7,43274.3
sent1,80118.0
queued6126.1
failed1551.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/

Webhook signing

Verify the delivery callbacks so nobody fakes “delivered” status. → /webhooks/signing/