Skip to main content

Blast (Bulk Send)

Fan one message payload out to many recipients with pacing, dedup, retry, and a dead-letter queue. Added in v0.8.0.

A blast job stores one message body plus a list of recipients, then a single background worker drains the list at a controlled pace, replaying the same send core used by regular and scheduled sends for each recipient.

:::note Single sequential worker, by design The worker processes exactly one job, one recipient at a time, across the whole instance. WhatsApp rate-limits and bans aggressive senders, so running multiple blast workers in parallel would only multiply ban risk. Pacing comes entirely from each job's delay_ms / jitter_ms, not from concurrency. :::

Create Blast​

POST /api/v1/sessions/{session_id}/blast

Request Body​

{
"endpoint": "text",
"body": {
"to": "placeholder@s.whatsapp.net",
"text": "Hello from our monthly newsletter!"
},
"recipients": ["628123456789", "628987654321"],
"delay_ms": 1000,
"jitter_ms": 250,
"max_attempts": 3,
"dedup_across_jobs": false,
"send_at": "2026-08-01T09:00:00Z"
}
FieldTypeRequiredDescription
endpointstringYesSend-endpoint key — same keys used internally by scheduled send: text, image, cta-url, etc.
bodyobjectYesRequest payload matching that endpoint's normal request shape. to is required for shape validation but is overwritten per recipient at send time; any send_at inside it is ignored/forced null
recipientsarray of stringYesJIDs or bare phone numbers. Must not be empty
delay_msintegerNoBase pause between sends, ms. Default 1000
jitter_msintegerNoExtra random 0..=jitter_ms pause added per send. Default 0
max_attemptsintegerNoSend attempts per recipient before it lands in the DLQ. Default 3
dedup_across_jobsbooleanNoSkip recipients any previous blast job on this session already delivered to (status sent). Default false
send_atstring (ISO-8601 UTC)NoDelayed job start — the job stays pending until this time, then the worker picks it up. Does not use the scheduler table; this is a separate, job-level gate

Recipients are validated and deduplicated at creation time, before any row is written:

  • Any unparseable recipient fails the whole request with 400 — no partial jobs.
  • Duplicate entries within the recipients array are collapsed; only the first occurrence is accepted, the rest are recorded as skipped_dup recipients (visible, not silently dropped).
  • If dedup_across_jobs is true, recipients already marked sent by an earlier blast job on the same session are also skipped as skipped_dup.

Response​

{
"job_id": "b3f1c2a4-1234-4cde-9f00-abcdef123456",
"total": 1500,
"skipped_dup": 12,
"status": "pending"
}

total is the recipient count actually accepted into the job (after dedup). status is always pending at creation — the worker picks it up on its own schedule.

Delivery semantics​

  • Pacing — one send at a time, delay_ms plus a uniform random 0..=jitter_ms pause between every send. No other throttling.
  • Retry — a recipient send that fails with attempts remaining goes back to pending and is retried on a later worker pass, with the same per-send delay (no extra backoff — max_attempts is the only cap).
  • Dead-letter queue — a recipient send that fails on its last allowed attempt moves to dlq and stays there. It is not automatically retried again; only /retry requeues DLQ recipients.
  • Progress events — a blast_progress webhook event fires every 25 successful sends, carrying the job's current counters.
  • Completion — once no pending recipients remain, the job closes as completed (nothing in the DLQ) or completed_with_failures (at least one DLQ recipient), and a blast_completed webhook event fires with the final counters.
  • Cancellation is cooperative — the worker checks the job's status before each batch and stops as soon as it's no longer running. Any recipients not yet processed stay pending, so a job can be resumed by nothing more than the worker claiming it again (it will not resume once cancelled, though — see Cancel Blast).

List Session Blasts​

GET /api/v1/sessions/{session_id}/blasts

Query Parameters​

FieldTypeRequiredDescription
statusstringNoFilter to one status: pending, running, completed, completed_with_failures, cancelled, failed

Response​

{
"jobs": [
{
"id": "b3f1c2a4-1234-4cde-9f00-abcdef123456",
"session_id": "main",
"endpoint": "text",
"status": "running",
"options": {
"delay_ms": 1000,
"jitter_ms": 250,
"max_attempts": 3,
"dedup_across_jobs": false
},
"total": 1500,
"sent_count": 890,
"failed_count": 4,
"dlq_count": 2,
"skipped_dup_count": 12,
"send_at": null,
"created_at": "2025-12-31T10:00:00Z",
"started_at": "2025-12-31T10:00:05Z",
"finished_at": null
}
],
"count": 1
}

failed_count counts every failed attempt including ones that later succeeded on retry; dlq_count is only recipients that exhausted all attempts.

Get Blast​

Job detail with the same shape as one entry above.

GET /api/v1/sessions/{session_id}/blasts/{id}

List Blast Recipients​

Paginated per-recipient status.

GET /api/v1/sessions/{session_id}/blasts/{id}/recipients

Query Parameters​

FieldTypeRequiredDescription
statusstringNoFilter to one status: pending, sending, sent, failed, dlq, skipped_dup
limitintegerNoPage size. Default 100, max 1000
offsetintegerNoRows to skip. Default 0

Response​

{
"recipients": [
{
"id": 42,
"recipient": "628123456789@s.whatsapp.net",
"status": "sent",
"attempts": 1,
"last_error": null,
"message_id": "3EB0C8F1A2B3C4D5E6",
"updated_at": "2025-12-31T10:00:12Z"
}
],
"count": 1
}

Cancel Blast​

Stops a job from sending further. Recipients not yet processed remain pending but the worker will not pick them up again once the job is cancelled.

POST /api/v1/sessions/{session_id}/blasts/{id}/cancel

Returns 400 if the job is already in a terminal state (completed, completed_with_failures, cancelled, failed).

Response​

Same BlastJob shape as Get Blast, with status cancelled.

Retry Blast​

Requeues every dlq (and failed) recipient back to pending and reopens the job so the worker picks it up again.

POST /api/v1/sessions/{session_id}/blasts/{id}/retry

Returns 400 if the job is still running (cancel it first) or if there is nothing in the DLQ to retry.

Response​

Same BlastJob shape as Get Blast.

List All Blasts (fleet-wide)​

GET /api/v1/blasts

Query Parameters​

FieldTypeRequiredDescription
sessionstringNoRestrict to this session id
statusstringNoFilter to one status

Response​

Same shape as List Session Blasts.