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 authentication check, and one idempotency lookup. At a thousand notifications that is a thousand of each.

POST /v1/events/bulk removes the per-notification HTTP, auth and idempotency overhead — but not the per-event rate-limit evaluation, which still runs for every event. It 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​

# -X POST, one JSON body, one round trip.
response=$(curl -sS -X POST https://api.nervly.io/v1/events/bulk \
--connect-timeout 5 \
--max-time 30 \
-H "Authorization: Bearer $NERVLY_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" }
}
]
}')

# Inspect per-event outcomes rather than assuming the whole batch succeeded.
echo "job: $(printf '%s' "$response" | jq -r '.jobId')"
echo "queued: $(printf '%s' "$response" | jq -r '.count'), failed: $(printf '%s' "$response" | jq -r '.failedCount')"
printf '%s' "$response" | jq -r '.events[] | select(.status == "FAILED") | "event \(.index) rejected: \(.error)"'

The result:

{
"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. Iterate events, keep the index of every FAILED result, and rebuild a smaller batch from just those entries — do not resend the whole payload.

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 ignored for bulk. Every event in a batch is published to the NOTIFY_BULK stream (notify.bulk.{channel}), regardless of the X-Priority-Override header. A batch cannot mix lanes: if you need a CRITICAL event to jump the queue, send it through POST /v1/events/trigger instead.

Rate limits still apply, per event. Batching saves round trips, not quota. Every event is evaluated against the workspace budget and the per-subscriber abuse cap exactly as a single trigger would be, so a batch aimed at one subscriber can exhaust that subscriber's cap and see later entries come back FAILED with a rate-limit error, while the rest of your traffic is unaffected. The monthly accepted-event quota is also applied per event.

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. Priority is ignored for bulk, so there is no lane to choose; group events by workflow instead.
  • Retry only the failures. Use the index values from events to rebuild a smaller batch rather than resending the whole thing.

Next​