IngestionBatch API

Batch API

The Batch endpoint allows you to submit multiple events in a single HTTP request. This is useful for data imports, historical backfills, and high-throughput server-side integrations where reducing HTTP overhead matters.

Endpoint

POST /v1/batch

Authentication

The Batch endpoint supports both authentication methods:

  1. HMAC-SHA256 (recommended for server-to-server) — same as the Server-Side Events endpoint
  2. Pipeline key via Authorization: Bearer dk_... header

Request Format

The request body is a JSON object with a single batch array containing the events:

{
  "batch": [
    {
      "type": "track",
      "event": "Order Completed",
      "userId": "user_001",
      "properties": {
        "order_id": "ORD-001",
        "revenue": 99.99,
        "currency": "USD"
      },
      "timestamp": "2026-02-24T10:00:00.000Z"
    },
    {
      "type": "track",
      "event": "Order Completed",
      "userId": "user_002",
      "properties": {
        "order_id": "ORD-002",
        "revenue": 249.50,
        "currency": "USD"
      },
      "timestamp": "2026-02-24T10:05:00.000Z"
    },
    {
      "type": "identify",
      "userId": "user_001",
      "traits": {
        "email": "alice@example.com",
        "name": "Alice Johnson",
        "plan": "premium"
      },
      "timestamp": "2026-02-24T10:00:00.000Z"
    }
  ]
}

Each item in the batch array follows the same schema as a single event sent to the Server-Side Events endpoint.

Limits

ConstraintDefault
Maximum events per batch500
Maximum payload size1 MB

These are deployment settings with fixed upper bounds; see Size Limits for every limit and how to raise it.

⚠️

If the batch exceeds the event limit, the entire request is rejected with a 413 Payload Too Large response. Split large imports into multiple requests, or use the server-side endpoint, which accepts 2,000 events and 10 MB per request by default.

Response

The response lists what happened to each event, by messageId:

{
  "accepted": ["msg-001", "msg-002", "msg-003"],
  "duplicates": 1,
  "rejected": [
    { "message_id": "msg-003", "reason": "invalid" }
  ]
}
FieldMeaning
acceptedEvents you can stop sending: published, already received, or refused on their own (those are also in rejected).
duplicatesHow many events carried a messageId Signal had already received in the last 24 hours. They are acknowledged and not published again.
rejectedEvents refused on their own, with the reason. The rest of the batch is accepted.

An event that cannot be accepted on its own is refused without failing the rest of the batch:

ReasonMeaning
invalidThe event could not be read, for example a timestamp that is not RFC 3339 or a number where text is expected.
too_deepProperties or traits nested more deeply than the deployment allows.
too_many_propertiesMore properties and traits than the deployment allows.
property_too_largeA property value longer than the deployment allows.
unknown_typetype is not one of page, track, screen, identify or group.
too_largeThe event is larger than the event queue accepts as one message (about 1 MB once Signal has added its own fields).

Refused events appear in the Event Debugger with the reason.

Status codes:

StatusMeaning
200Every event is published, a duplicate, or listed in rejected.
400The body is not valid JSON, or has no batch array.
401 / 403Authentication failed, or the pipeline refuses the request.
413The body or the number of events is over the limit. Split the batch.
429Rate-limited. Send again after Retry-After seconds.
503Send the whole batch again after Retry-After seconds. Not every event could be written yet: the event queue failed, an earlier request is still processing one of them, or a backend Signal needs to check the request was unavailable. The body lists what was accepted.

A 503 never means some events were dropped. Resending the whole batch is safe when every event carries a messageId: events already received are acknowledged as duplicates and published once. An event without a messageId is given a new one on arrival, so it cannot be de-duplicated.

The body may be sent as application/json or as text/plain, which is what a browser’s sendBeacon uses.

Example: Historical Backfill

Here is a Node.js example that reads events from a JSON file and submits them in batches of 500:

import crypto from "node:crypto";
import { readFileSync } from "node:fs";
 
const SECRET_KEY = "sk_live_your_secret_key";
const ENDPOINT = "https://data.example.com/v1/batch";
const BATCH_SIZE = 500;
 
async function sendBatch(events) {
  const timestamp = Date.now().toString();
  const body = JSON.stringify({ batch: events });
  const signature = crypto
    .createHmac("sha256", SECRET_KEY)
    .update(`${timestamp}.${body}`)
    .digest("hex");
 
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Signature": `sha256=${signature}`,
      "X-Timestamp": timestamp,
    },
    body,
  });
 
  return response.json();
}
 
// Load events from a JSON file
const allEvents = JSON.parse(readFileSync("events.json", "utf-8"));
 
// Send in batches of 500
for (let i = 0; i < allEvents.length; i += BATCH_SIZE) {
  const batch = allEvents.slice(i, i + BATCH_SIZE);
  const result = await sendBatch(batch);
  console.log(
    `Batch ${Math.floor(i / BATCH_SIZE) + 1}: ${result.accepted.length} accepted`
  );
 
  if (result.rejected?.length) {
    console.warn("Refused:", result.rejected);
  }
}

Example: Python Backfill

import hashlib
import hmac
import json
import time
import requests
 
SECRET_KEY = "sk_live_your_secret_key"
ENDPOINT = "https://data.example.com/v1/batch"
BATCH_SIZE = 500
 
 
def send_batch(events: list[dict]) -> dict:
    timestamp = str(int(time.time() * 1000))
    body = json.dumps({"batch": events}, separators=(",", ":"))
    signature = hmac.new(
        SECRET_KEY.encode(),
        f"{timestamp}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()
 
    response = requests.post(
        ENDPOINT,
        headers={
            "Content-Type": "application/json",
            "X-Signature": f"sha256={signature}",
            "X-Timestamp": timestamp,
        },
        data=body,
    )
    return response.json()
 
 
# Load events
with open("events.json") as f:
    all_events = json.load(f)
 
# Send in batches
for i in range(0, len(all_events), BATCH_SIZE):
    batch = all_events[i : i + BATCH_SIZE]
    result = send_batch(batch)
    print(f"Batch {i // BATCH_SIZE + 1}: {len(result['accepted'])} accepted")

Use Cases

Use CaseDescription
Historical backfillImport past events from a data warehouse or CSV export
Data migrationMigrate events from another analytics platform
High-throughput ingestionReduce HTTP overhead by batching events from a busy server
Periodic syncBatch events collected offline and send them on a schedule

Best Practices

  • Set accurate timestamps. For historical imports, always include the timestamp field with the original event time. The processing layer and downstream vendors rely on this for attribution.
  • Include messageId for deduplication. If you might retry a batch (e.g. after a network error), include a messageId on each event so the Signal can deduplicate.
  • Respect rate limits. If you receive a 429 response, back off using the Retry-After header value before sending the next batch.
  • Resend on 503. Send the same batch again, with the same messageId values, after Retry-After seconds.
  • Monitor refused events. Check the rejected array in the response and fix or log the events it lists.