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
- cURL
- Node.js / TypeScript
- Python
- Go
- PHP
- Rust
- Java
- C#
- Ruby
- Swift
# -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)"'
import { Nervly } from '@nervly/sdk';
const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
const batch = await nervly.events.bulkTrigger({
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' },
},
],
});
console.log(`Job ${batch.jobId}: ${batch.count} queued, ${batch.failedCount} failed`);
for (const result of batch.events) {
if (result.status === 'FAILED') {
console.error(`event ${result.index} rejected: ${result.error}`);
} else {
console.log(`event ${result.index} queued as ${result.eventId} on ${result.channel}`);
}
}
import os
import requests
batch = {
"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"},
},
]
}
response = requests.post(
"https://api.nervly.io/v1/events/bulk",
headers={
"Authorization": f"Bearer {os.environ['NERVLY_API_KEY']}",
"Content-Type": "application/json",
},
json=batch,
timeout=30,
)
response.raise_for_status()
job = response.json()
print(f"Job {job['jobId']}: {job['count']} queued, {job['failedCount']} failed")
for result in job["events"]:
if result["status"] == "FAILED":
print(f"event {result['index']} rejected: {result['error']}")
else:
print(f"event {result['index']} queued as {result['eventId']} on {result['channel']}")
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type bulkEventResult struct {
Index int `json:"index"`
Status string `json:"status"`
EventID string `json:"eventId"`
Channel string `json:"channel"`
Error string `json:"error"`
}
type bulkResponse struct {
JobID string `json:"jobId"`
Status string `json:"status"`
Count int `json:"count"`
FailedCount int `json:"failedCount"`
Events []bulkEventResult `json:"events"`
}
func main() {
payload := map[string]any{
"events": []any{
map[string]any{
"name": "weekly-digest",
"to": map[string]string{"subscriberId": "user_8f21c", "email": "ada@example.com"},
"payload": map[string]string{"unread": "12"},
},
map[string]any{
"name": "weekly-digest",
"to": map[string]string{"subscriberId": "user_3a90d", "email": "grace@example.com"},
"payload": map[string]string{"unread": "4"},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
req, err := http.NewRequest("POST", "https://api.nervly.io/v1/events/bulk", bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("NERVLY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
panic(fmt.Sprintf("Nervly returned %d", resp.StatusCode))
}
var batch bulkResponse
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
panic(err)
}
fmt.Printf("Job %s: %d queued, %d failed\n", batch.JobID, batch.Count, batch.FailedCount)
for _, result := range batch.Events {
if result.Status == "FAILED" {
fmt.Printf("event %d rejected: %s\n", result.Index, result.Error)
} else {
fmt.Printf("event %d queued as %s on %s\n", result.Index, result.EventID, result.Channel)
}
}
}
<?php
$batch = [
'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'],
],
],
];
$ch = curl_init('https://api.nervly.io/v1/events/bulk');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('NERVLY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($batch),
CURLOPT_TIMEOUT => 30,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("batch rejected with HTTP {$status}: {$response}");
}
$job = json_decode($response, true);
printf("Job %s: %d queued, %d failed\n", $job['jobId'], $job['count'], $job['failedCount']);
foreach ($job['events'] as $result) {
if ($result['status'] === 'FAILED') {
printf("event %d rejected: %s\n", $result['index'], $result['error']);
} else {
printf("event %d queued as %s on %s\n", $result['index'], $result['eventId'], $result['channel']);
}
}
use reqwest::Client;
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::error::Error;
use std::time::Duration;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkEventResult {
index: usize,
status: String,
event_id: Option<String>,
channel: Option<String>,
error: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkTriggerResponse {
job_id: String,
count: usize,
failed_count: usize,
events: Vec<BulkEventResult>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let payload = json!({
"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" }
}
]
});
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let response = client
.post("https://api.nervly.io/v1/events/bulk")
.header("Authorization", format!("Bearer {}", env::var("NERVLY_API_KEY")?))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
let status = response.status();
let raw = response.text().await?;
if !status.is_success() {
return Err(format!("Nervly returned {status}: {raw}").into());
}
let batch: BulkTriggerResponse = serde_json::from_str(&raw)?;
println!(
"Job {}: {} queued, {} failed",
batch.job_id, batch.count, batch.failed_count
);
for result in &batch.events {
if result.status == "FAILED" {
eprintln!(
"event {} rejected: {}",
result.index,
result.error.as_deref().unwrap_or("unknown")
);
} else {
println!(
"event {} queued as {:?} on {:?}",
result.index, result.event_id, result.channel
);
}
}
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class BulkTrigger {
// Reads any flat result field by name, so this does not depend on the order
// the gateway happens to serialise `index`/`status`/`eventId`/`error`. It
// stays dependency-free so the example compiles with a bare JDK; in
// production, decode the body with a JSON library such as Jackson.
// ASCII double-quote, named so string scanning does not need a char literal.
private static final char QUOTE = 34;
private static final Pattern FIELD = Pattern.compile(
"\"(index|status|eventId|channel|error)\"\\s*:\\s*(\"(?:\\\\.|[^\"\\\\])*\"|\\d+)");
/** The text between the brackets of the named top-level array, or null. */
private static String arrayBody(String json, String name) {
int key = json.indexOf("\"" + name + "\"");
if (key < 0) return null;
int open = json.indexOf('[', key);
if (open < 0) return null;
int depth = 0;
boolean inString = false;
boolean escaped = false;
for (int i = open; i < json.length(); i++) {
char c = json.charAt(i);
if (inString) {
if (escaped) escaped = false;
else if (c == '\\') escaped = true;
else if (c == QUOTE) inString = false;
} else if (c == QUOTE) {
inString = true;
} else if (c == '[') {
depth++;
} else if (c == ']' && --depth == 0) {
return json.substring(open + 1, i);
}
}
return null;
}
/** Every top-level `{...}` object of an array body, ignoring nesting. */
private static List<String> objects(String arrayBody) {
List<String> objects = new ArrayList<>();
int depth = 0;
int start = -1;
boolean inString = false;
boolean escaped = false;
for (int i = 0; i < arrayBody.length(); i++) {
char c = arrayBody.charAt(i);
if (inString) {
if (escaped) escaped = false;
else if (c == '\\') escaped = true;
else if (c == QUOTE) inString = false;
continue;
}
if (c == QUOTE) inString = true;
else if (c == '{') {
if (depth == 0) start = i;
depth++;
} else if (c == '}' && --depth == 0 && start >= 0) {
objects.add(arrayBody.substring(start, i + 1));
}
}
return objects;
}
private static Map<String, String> fieldsOf(String object) {
Map<String, String> fields = new LinkedHashMap<>();
Matcher matcher = FIELD.matcher(object);
while (matcher.find()) {
String raw = matcher.group(2);
fields.putIfAbsent(matcher.group(1), raw.charAt(0) == QUOTE ? raw.substring(1, raw.length() - 1) : raw);
}
return fields;
}
public static void main(String[] args) throws Exception {
String payload = """
{
"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" }
}
]
}
""";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.nervly.io/v1/events/bulk"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("NERVLY_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new IllegalStateException("Nervly returned " + response.statusCode() + ": " + response.body());
}
String results = arrayBody(response.body(), "events");
if (results == null) {
throw new IllegalStateException("response has no events array");
}
for (String event : objects(results)) {
Map<String, String> fields = fieldsOf(event);
String index = fields.getOrDefault("index", "?");
if ("FAILED".equals(fields.get("status"))) {
System.err.println("event " + index + " rejected: " + fields.getOrDefault("error", "unknown"));
} else {
System.out.println("event " + index + " queued as "
+ fields.getOrDefault("eventId", "(none)") + " on "
+ fields.getOrDefault("channel", "(none)"));
}
}
}
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
public class Program
{
public static async Task Main()
{
var payload = new
{
events = new object[]
{
new
{
name = "weekly-digest",
to = new { subscriberId = "user_8f21c", email = "ada@example.com" },
payload = new { unread = "12" },
},
new
{
name = "weekly-digest",
to = new { subscriberId = "user_3a90d", email = "grace@example.com" },
payload = new { unread = "4" },
},
},
};
var body = JsonSerializer.Serialize(payload);
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.nervly.io/v1/events/bulk");
request.Headers.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("NERVLY_API_KEY"));
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Nervly returned {(int)response.StatusCode}: {responseBody}");
}
var batch = JsonNode.Parse(responseBody)!;
Console.WriteLine($"Job {batch["jobId"]}: {batch["count"]} queued, {batch["failedCount"]} failed");
foreach (var result in batch["events"]!.AsArray())
{
if (result!["status"]!.GetValue<string>() == "FAILED")
{
Console.Error.WriteLine($"event {result["index"]} rejected: {result["error"]}");
}
else
{
Console.WriteLine($"event {result["index"]} queued as {result["eventId"]} on {result["channel"]}");
}
}
}
}
require 'json'
require 'net/http'
require 'uri'
batch = {
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' }
}
]
}
uri = URI('https://api.nervly.io/v1/events/bulk')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('NERVLY_API_KEY')}"
request['Content-Type'] = 'application/json'
request.body = batch.to_json
http = Net::HTTP.new(uri.hostname, uri.port)
http.use_ssl = true
http.open_timeout = 30
http.read_timeout = 30
response = http.request(request)
unless response.is_a?(Net::HTTPSuccess)
raise "Nervly returned #{response.code}: #{response.body}"
end
job = JSON.parse(response.body)
puts "Job #{job['jobId']}: #{job['count']} queued, #{job['failedCount']} failed"
job['events'].each do |result|
if result['status'] == 'FAILED'
warn "event #{result['index']} rejected: #{result['error']}"
else
puts "event #{result['index']} queued as #{result['eventId']} on #{result['channel']}"
end
end
import Foundation
struct Recipient: Codable {
let subscriberId: String
let email: String
}
struct TriggerRequest: Codable {
let name: String
let to: Recipient
let payload: [String: String]
}
struct BulkTriggerRequest: Codable {
let events: [TriggerRequest]
}
struct BulkEventResult: Codable {
let index: Int
let status: String
let eventId: String?
let channel: String?
let error: String?
}
struct BulkTriggerResponse: Codable {
let jobId: String
let count: Int
let failedCount: Int
let events: [BulkEventResult]
}
let batch = BulkTriggerRequest(events: [
TriggerRequest(
name: "weekly-digest",
to: Recipient(subscriberId: "user_8f21c", email: "ada@example.com"),
payload: ["unread": "12"]
),
TriggerRequest(
name: "weekly-digest",
to: Recipient(subscriberId: "user_3a90d", email: "grace@example.com"),
payload: ["unread": "4"]
),
])
let body = try JSONEncoder().encode(batch)
var urlRequest = URLRequest(url: URL(string: "https://api.nervly.io/v1/events/bulk")!)
urlRequest.httpMethod = "POST"
urlRequest.timeoutInterval = 30
urlRequest.setValue("Bearer \(ProcessInfo.processInfo.environment["NERVLY_API_KEY"]!)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = body
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
defer { semaphore.signal() }
if let error = error {
print("Request failed: \(error)")
return
}
guard let http = response as? HTTPURLResponse, let data = data else { return }
guard (200..<300).contains(http.statusCode) else {
print("Nervly returned \(http.statusCode): \(String(data: data, encoding: .utf8) ?? "")")
return
}
guard let job = try? JSONDecoder().decode(BulkTriggerResponse.self, from: data) else { return }
print("Job \(job.jobId): \(job.count) queued, \(job.failedCount) failed")
for result in job.events {
if result.status == "FAILED" {
print("event \(result.index) rejected: \(result.error ?? "unknown")")
} else {
print("event \(result.index) queued as \(result.eventId ?? "") on \(result.channel ?? "")")
}
}
}.resume()
semaphore.wait()
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.
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. Priority is ignored for bulk, so there is no lane to choose; group events by workflow instead.
- 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