Batch ingest
Batch ingest accepts up to 100 events in a single POST. It exists for two situations: replaying a backlog (a migration, a queue drain) and amortizing HTTP overhead on a hot emit path. Outside those cases, prefer single-event ingest, the per-event overhead is already small, and one event per request keeps your retry math simple.
When to batch
Batch when you have many events ready at once and the latency of grouping them is acceptable. Common cases: bulk-importing historical data, draining a local queue on shutdown, or wrapping a tight loop that emits one event per row.
Do not batch by holding events client-side and flushing on a timer unless your SDK is doing it for you. The Node and Python SDKs have a buffered mode that handles the timing and shutdown correctly; rolling your own usually produces a worse outcome on graceful-shutdown edge cases.
Request shape
The batch endpoint wraps an array of canonical events under an events key. The per-event shape is identical to single ingest.
curl -X POST https://api.pushrail.io/events/batch \
-H "Authorization: Bearer $PUSHRAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"eventType": "order.completed",
"occurredAt": "2026-05-16T12:00:00Z",
"source": "checkout-service",
"customerExternalId": "cust_42",
"payload": { "orderId": "ord_1" }
},
{
"eventType": "order.completed",
"occurredAt": "2026-05-16T12:00:01Z",
"source": "checkout-service",
"customerExternalId": "cust_99",
"payload": { "orderId": "ord_2" }
}
]
}'
The server cap is 100 events per request. The wire payload size limit is 5 MB; if you regularly hit the size cap before the count cap, split your batches by total payload size, not just by count.
Partial failure semantics
The batch endpoint returns 200 OK with a per-event result array. A batch is never all-or-nothing, events that validated are accepted and persisted, events that failed validation are reported in the response with their original index.
{
"accepted": 1,
"rejected": 1,
"events": [
{ "index": 0, "id": "evt_abc", "status": "accepted" },
{ "index": 1, "status": "rejected", "error": "payload.amount: expected number, got string" }
]
}
Match by index, not by position, the array is ordered but your code should not depend on alignment. Rejected events are not retryable from the server side; fix the payload and re-emit, or drop them.
If the request itself fails (auth, rate-limit, 5xx), no events were accepted, the whole batch failed at the request layer and the SDK or your code should retry the whole batch with the same idempotencyKeys, so duplicates collapse at the per-event dedup layer.
Example
A migration script draining a Postgres table into Pushrail in chunks of 100:
import { Pushrail } from "@pushrail/sdk";
const pushrail = new Pushrail({ apiKey: process.env.PUSHRAIL_API_KEY! });
async function migrate(rows: OrderRow[]) {
for (let i = 0; i < rows.length; i += 100) {
const chunk = rows.slice(i, i + 100);
const result = await pushrail.events.emitBatch(
chunk.map((row) => ({
eventType: "order.completed",
source: "migration",
customerExternalId: row.customerId,
occurredAt: row.createdAt.toISOString(),
idempotencyKey: `migration:order:${row.id}`,
payload: { orderId: row.id, totalCents: row.totalCents },
})),
);
if (result.rejected > 0) {
console.warn("Rejected:", result.events.filter((e) => e.status === "rejected"));
}
}
}
Using a deterministic idempotencyKey (the row id, namespaced) makes the script safely re-runnable, a partial run plus a full re-run produces the same end state with zero double-delivery.