Skip to main content

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.

Make batch retries safe yourself

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 200 and 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 index values from events to rebuild a smaller batch rather than resending the whole thing.

Next