Bulk sending
Why not just loop
Looping on POST /v1/events/trigger
costs you, per notification: one TLS round trip, one rate-limit decision, one
idempotency lookup. At a thousand notifications that is a thousand of each.
POST /v1/events/bulk takes
one round trip and one authentication check for the whole batch, and publishes
the events concurrently instead of one broker round trip at a time.
Sending a batch
curl -X POST https://api.nervly.io/v1/events/bulk \
-H "Authorization: Bearer $NERVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"name": "weekly-digest",
"to": { "subscriberId": "user_8f21c", "email": "ada@example.com" },
"payload": { "unread": "12" }
},
{
"name": "weekly-digest",
"to": { "subscriberId": "user_3a90d", "email": "grace@example.com" },
"payload": { "unread": "4" }
}
]
}'
{
"jobId": "job_batch_4d2f8a1c9b3e7f60",
"status": "QUEUED",
"count": 2,
"failedCount": 0,
"events": [
{ "index": 0, "eventId": "evt_9c4f1a2b…", "status": "QUEUED", "channel": "email" },
{ "index": 1, "eventId": "evt_7b3e1d8a…", "status": "QUEUED", "channel": "email" }
]
}
Each entry has exactly the same shape as a single-event body, and is validated, routed and published by exactly the same code — so anything accepted here behaves identically to a single call.
Events succeed and fail individually
Read failedCount, not the HTTP status. A batch where some events were
rejected still returns 200, with status set to PARTIAL:
{
"jobId": "job_batch_4d2f8a1c9b3e7f60",
"status": "PARTIAL",
"count": 1,
"failedCount": 1,
"events": [
{ "index": 0, "eventId": "evt_9c4f1a2b…", "status": "QUEUED", "channel": "email" },
{
"index": 1,
"status": "FAILED",
"error": "Bad Request: Subscriber user_3a90d has no reachable channel: supply at least one of to.email, to.phone or to.deviceTokens"
}
]
}
index is the position in the events array you submitted, so a failure maps
straight back to your input. A rejected event never blocks the rest of the
batch.
The whole batch is rejected with 400 before anything is published only for
batch-level problems: an empty events array, or more than 5,000 entries.
Limits and caveats
Priority is per batch, not per event. Set X-Priority-Override on the
request and it applies to every event in it. Group by priority if you need
different lanes.
Rate limits still apply per subscriber. Batching saves round trips, not
quota. A batch aimed at one subscriber can exhaust that subscriber's budget and
see later entries come back FAILED with a rate-limit error, while the rest of
your traffic is unaffected.
No batch-level idempotency. The Idempotency-Key header is honoured on the
single-event endpoint, not on the batch endpoint: one key cannot identify many
events. A retried batch re-enqueues every event in it.
Because there is no batch idempotency, a timeout on a large batch leaves you unable to tell whether it was accepted. Either:
- record the response only on a confirmed
200and treat a timeout as unknown, then reconcile from delivery receipts before resending, or - send in smaller batches so a resend duplicates less, or
- use the single-event endpoint with derived idempotency keys when duplicates are genuinely unacceptable.
Sizing batches
- Hard maximum 5,000 events. The whole batch is held in memory and every event is published before the response is written, so larger requests are rejected rather than allowed to hold a connection open indefinitely.
- Aim for ~1,000 per batch. Comfortably inside the limit, and small enough that a resend after a timeout duplicates little.
- Group by workflow and priority. Priority is a header, so a batch cannot mix lanes.
- Retry only the failures. Use the
indexvalues fromeventsto rebuild a smaller batch rather than resending the whole thing.
Next
- Rate limits — why batching helps
- Idempotency
- Trigger notifications in bulk