Bulk Campaigns Guide
From CSV to delivered campaign — import contacts, schedule, monitor live stats, and handle failures with per-recipient tracking.
This end-to-end guide builds a campaign workflow: import a contact list, schedule the send, watch live stats, and build a retry list from failures.
#1. Import your audience
# Create a list
curl -s -X POST https://api.sendafrica.online/v1/contact-lists/ \
-H "Authorization: Bearer $JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "June Promo"}'
# Import contacts from CSV
curl -s -X POST https://api.sendafrica.online/v1/contact-lists/3/import \
-H "Authorization: Bearer $JWT_TOKEN" \
-F "file=@customers.csv"
# → { "imported": 482, "skipped": 3, "errors": [...] }The import response includes row-level errors — fix those rows and re-import only the failures rather than the whole file (duplicates return 409 duplicate_contact).
#2. Estimate cost & check balance
analysis = client.sms.analyze(
"Mambo! Enjoy 20% off all weekend. Karibu!"
)
print(analysis.parts) # 1
balance = client.credits.balance()
audience = 482
if balance.balance < analysis.credits * audience:
print("Top up first — see /docs/api/payments")#3. Schedule the campaign
/v1/campaigns/JWTcurl -s -X POST https://api.sendafrica.online/v1/campaigns/ \
-H "Authorization: Bearer $JWT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: june-promo-v1" \
-d '{
"name": "June Promo Blast",
"message": "Mambo! Enjoy 20% off all weekend. Karibu!",
"contact_list_id": 3,
"scheduled_at": "2026-06-20T09:00:00Z",
"sender_id": "MyBrand"
}'Best send windows (TZ)
Local experience says 08:00–11:00 and 17:00–20:00 EAT perform best. Avoid Friday-afternoon prayer times and month-end when airtime wallets run low.
#4. Monitor live stats
async function pollCampaign(jwt: string, id: number) {
while (true) {
const res = await fetch(
`https://api.sendafrica.online/v1/campaigns/${id}`,
{ headers: { Authorization: `Bearer ${jwt}` } },
);
const { data } = await res.json();
console.log(
data.status,
`${data.sent}/${data.total_recipients} sent,`,
`${data.delivered} delivered`,
);
if (data.status === "completed" || data.status === "failed") break;
await new Promise((r) => setTimeout(r, 30_000)); // worker ticks every 30s
}
}#5. Build a retry list from failures
curl -s "https://api.sendafrica.online/v1/campaigns/42/recipients?status=failed" \
-H "Authorization: Bearer $JWT_TOKEN"Inspect each failure's reason: invalid_phone_number rows should be cleaned from the source list permanently; carrier timeouts can be retried once after 30 minutes.
#Operational notes
- The worker sends per-recipient (chunks of 500), not as one gateway bulk call — credit accounting stays exact per contact.
- Only
draft/scheduledcampaigns can be cancelled; processing campaigns are locked by a Redis distributed lock. - Opted-out contacts (STOP replies) are excluded automatically at execution time.
- For ≤100 recipients one-off blasts,
POST /v1/sms/bulkis simpler than a campaign.