---
title: Batch and retry HTTP V2 API requests
description: "Batch events, retry failed requests with backoff, and handle partial failures in the HTTP V2 API."
product: analytics
lang: en
last_updated: 2026-11-05
token_estimate: 2448
---
# Batch and retry HTTP V2 API requests

To send server-side events reliably, post up to 2,000 events in each HTTP V2 API request and give every event an `insert_id`. Retry `429` and `5xx` responses with backoff. When a `400` response lists rejected events, remove those events and resend the rest.

> For AI agents: a documentation index is available at [/docs/llms.txt](/docs/llms.txt). Append `.md` to any page URL for markdown, or send `Accept: text/markdown`.

Amplitude ignores an event that repeats an `insert_id` from the past 7 days, so a retry doesn't create duplicates. For the exact rule, go to [Event deduplication](/docs/apis/analytics/http-v2#event-deduplication).

This guide covers the HTTP V2 endpoint, `/2/httpapi`. To send historical data, or more than 30 events per second for one user or device, use the [Batch Event Upload API](https://amplitude.com/docs/apis/analytics/batch-event-upload) instead. That API accepts up to 1,000 events per second for each user or device, and requests up to 20 MB.

## Prerequisites

- The [API key](https://amplitude.com/docs/apis/authentication) for your Amplitude project.
- For the Node.js sample, Node.js 18 or later.

## Send a batch request

Send a `POST` request with a JSON body that contains your `api_key` and an `events` array. Each event needs an `event_type` and either a `user_id` or a `device_id`. Add an `insert_id` that stays the same for the event across every retry.

```bash
curl -X POST https://api2.amplitude.com/2/httpapi \
  -H 'Content-Type: application/json' \
  -d '{
    "api_key": "YOUR_API_KEY",
    "events": [
      {
        "user_id": "user-20391",
        "event_type": "Order Completed",
        "time": 1793817970000,
        "insert_id": "order-88231-completed"
      },
      {
        "user_id": "user-20391",
        "event_type": "Receipt Emailed",
        "time": 1793817971000,
        "insert_id": "order-88231-receipt"
      }
    ]
  }'
```

If your project uses Amplitude's EU data center, send requests to `https://api.eu.amplitude.com/2/httpapi`.

A successful response returns `200` and the number of events Amplitude accepted:

```json
{
  "code": 200,
  "server_upload_time": 1793817970568,
  "payload_size_bytes": 239,
  "events_ingested": 2
}
```

To reduce bandwidth, gzip the request body and add a `Content-Encoding: gzip` header. Amplitude counts events against the 2,000-event limit after it decompresses the body.

## Size your batches

Keep each request within these limits. The [HTTP V2 upload limits](https://amplitude.com/docs/apis/analytics/http-v2#upload-limit) are the canonical source.

| Limit | Value | Response when exceeded |
| --- | --- | --- |
| Events per request | 2,000 | `413` |
| Request body size | Under 1 MB | `413` |
| Events per user or device | 30 per second, averaged over a rolling 30-second window | `429` |
| Events per project | 50,000 per second across the HTTP API and HTTP V2 endpoints | `429` |

On the Free plan, send no more than 10 events in each request, 100 requests per second, and 1,000 events per second.

At high volume, partition work by `user_id` or `device_id`, so that throttling for one user or device doesn't delay events for everyone else.

## Retry failed requests

Decide whether to retry from the response status:

| Status | Meaning | What to do |
| --- | --- | --- |
| `200` | Amplitude accepted the events. | Nothing. |
| `400` | The request or some events are invalid. | Don't retry unchanged. Go to [Handle partial failures](#handle-partial-failures). |
| `403` | Amplitude's web application firewall blocked the request. | Don't retry. Check the request for values that security filters could match. |
| `413` | The request has more than 2,000 events or is too large. | Split the events into smaller requests, then send each one. |
| `429` | A user, a device, or the project exceeded its rate limit. | Wait 30 seconds, then retry. If the project exceeded its limit, wait 60 seconds. |
| `500`, `502`, `504` | Amplitude hit an error and may have accepted the events. | Retry with backoff. The `insert_id` values prevent duplicates. |
| `503` | Amplitude didn't accept the events. | Retry with backoff. |
| Network error or timeout | You don't know whether Amplitude accepted the events. | Retry with backoff. The `insert_id` values prevent duplicates. |

For `5xx` responses and network errors, use exponential backoff with jitter. Wait a random time up to 1 second, then up to 2 seconds, then 4 seconds, up to a cap. Jitter keeps many workers from retrying at the same moment. Set a maximum number of attempts, and log or store any events you can't send.

This Node.js function sends a batch and applies every rule in the status table:

```js
// Node.js 18 or later
const ENDPOINT = "https://api2.amplitude.com/2/httpapi"; // EU: https://api.eu.amplitude.com/2/httpapi
const API_KEY = process.env.AMPLITUDE_API_KEY;
const MAX_ATTEMPTS = 6;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Exponential backoff with full jitter: a random wait up to 1 s, 2 s, 4 s, ... capped at 60 s.
const backoff = (attempt) =>
  Math.random() * Math.min(60_000, 1_000 * 2 ** attempt);

// Indexes of the events a 400 response rejected.
function rejectedIndexes(body) {
  const indexes = new Set(body.silenced_events ?? []);
  for (const fields of [
    body.events_with_missing_fields,
    body.events_with_invalid_fields,
  ]) {
    for (const list of Object.values(fields ?? {}))
      list.forEach((i) => indexes.add(i));
  }
  return indexes;
}

export async function sendEvents(events, attempt = 0) {
  let res;
  try {
    res = await fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ api_key: API_KEY, events }),
    });
  } catch (err) {
    // Network error or timeout: Amplitude may or may not have the events, so retry.
    if (attempt + 1 >= MAX_ATTEMPTS) throw err;
    await sleep(backoff(attempt));
    return sendEvents(events, attempt + 1);
  }
  const body = await res.json().catch(() => ({}));

  if (res.ok) return body; // body.events_ingested

  if (res.status === 413 && events.length > 1) {
    // Too many events: split the batch in half and send each half.
    const mid = Math.ceil(events.length / 2);
    const first = await sendEvents(events.slice(0, mid));
    const second = await sendEvents(events.slice(mid));
    return { events_ingested: first.events_ingested + second.events_ingested };
  }

  if (res.status === 400) {
    const rejected = rejectedIndexes(body);
    if (rejected.size === 0) throw new Error(`Request rejected: ${body.error}`); // For example, an invalid API key.
    console.error(
      "Amplitude rejected these events:",
      [...rejected].map((i) => events[i]),
      body,
    );
    const rest = events.filter((_, i) => !rejected.has(i));
    return rest.length ? sendEvents(rest, attempt) : { events_ingested: 0 };
  }

  const retryable = res.status === 429 || res.status >= 500;
  if (!retryable || attempt + 1 >= MAX_ATTEMPTS) {
    throw new Error(
      `Upload failed with ${res.status}: ${JSON.stringify(body)}`,
    );
  }
  // 429: wait 30 s, then double the wait on each repeat. 5xx: back off with jitter.
  await sleep(
    res.status === 429
      ? Math.min(300_000, 30_000 * 2 ** attempt)
      : backoff(attempt),
  );
  return sendEvents(events, attempt + 1);
}
```

## Handle partial failures

A `400` response can name the specific events that failed, by their index in the `events` array. These fields map each field name to the indexes of every event with that problem:

- `events_with_missing_fields`: events that lack a required field, such as `event_type`, or have neither `user_id` nor `device_id`.
- `events_with_invalid_fields`: events with a value Amplitude can't accept, such as a `time` that isn't milliseconds since epoch, or a `user_id` shorter than 5 characters.

A response can include both fields. For example, this response reports a missing `event_type` at index 0 and an invalid `time` at index 1:

```json
{
  "code": 400,
  "error": "Request missing required field",
  "events_with_missing_fields": { "event_type": [0] },
  "events_with_invalid_fields": { "time": [1] }
}
```

A `400` for silenced devices lists the affected events in `silenced_events`.

To recover:

1. Collect every index from `events_with_missing_fields`, `events_with_invalid_fields`, and `silenced_events`.
2. Log or store those events so you can fix them later.
3. Resend the other events with their original `insert_id` values. If Amplitude already stored any of them, it ignores the repeats.

If a `400` response lists no event indexes, the whole request is invalid. Check the `error` value, such as `Invalid API key` or `Invalid JSON request body`.

A `429` response lists the events from throttled users or devices in `throttled_events`. To keep other events flowing, you can send the remaining events right away and hold only the throttled ones for 30 seconds. Resending the whole batch after the wait is also safe, because of the `insert_id` values.

For the full response schemas, go to the [HTTP V2 API response reference](https://amplitude.com/docs/apis/analytics/http-v2#response).

## Verify your events

1. Check that each response returns `200`, and that `events_ingested` matches the number of events you sent. `events_ingested` counts events Amplitude accepted, including repeats it later ignores because of their `insert_id`.
2. In Amplitude, open **User Lookup** and search for a `user_id` or `device_id` from the batch. Confirm the events appear on that user's stream.
3. To review accepted and rejected requests across the project, use the [Ingestion Debugger](https://amplitude.com/docs/analytics/debug-analytics#ingestion-debugger).

