# Batch requests

> Up to 50 requests in one call.

`POST /batch` runs 1 to 50 API requests in one call, in order, each with the token of the batch request. Any valid token can send a batch; each request in it needs its own rights.

## Request

| Key | Meaning |
|---|---|
| `requests` | 1 to 50 objects with `method` (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`), `path` (a `/public/v1/` path with its query string) and, if needed, `body` (the JSON body as an object). |

```bash
curl -X POST "$ECHO_API/batch" \
  -H "Authorization: Bearer $ECHO_TOKEN" -H "Content-Type: application/json" \
  -d '{"requests":[
        {"method":"GET","path":"/public/v1/subscribers/count?filter[status]=active"},
        {"method":"POST","path":"/public/v1/subscribers","body":{"email":"bence.kovacs@example.com","fields":{"city":"Sopron"}}},
        {"method":"GET","path":"/public/v1/groups/00000000-0000-0000-0000-000000000000/subscribers"}
      ]}'
```

## Answer

`200 OK` with one answer per request, in the same order. `code` is the request's HTTP status, `body` its JSON body (`null` when empty).

```json
{"responses":[{"code":200,"body":{"data":{"total":4714}}},{"code":200,"body":{"data":{"id":"8273ff7d-cb69-4a72-83e6-dbebbec33115","email":"bence.kovacs@example.com","status":"active","source":"import","fields":{"city":"Sopron","name":"Bence"},"groups":[{"id":"257e83a2-d55e-4a8c-b9a4-b3e939c151cc","name":"Webshop vásárlók"}],"signup_ip":null,"optin_ip":null,"subscribed_at":"2026-10-22T08:52:08.762873Z","opted_in_at":null,"unsubscribed_at":null,"timezone":null,"location_country":null,"location_city":null,"sent":0,"opens":0,"clicks":0,"open_rate":0,"click_rate":0,"created_at":"2026-10-22T08:52:08.762873Z","updated_at":"2026-10-22T08:52:10.377603Z"}}},{"code":404,"body":{"error":{"code":"not_found","message":"Not found."}}}]}
```

The batch answers `200` even when requests in it failed: check every `code`. In this run a request just before the batch left `X-RateLimit-Remaining: 86`, and the batch answer said `82`: the batch and its three requests.

## Rate limit

- The batch itself counts as one request, and every request in it counts too. A batch of 50 uses 51 of the 120 requests a minute.
- If the limit is already spent, the batch answers `429` and nothing runs.
- Once a request in the batch gets `429`, the remaining ones aren't run: each answers `429 rate_limited`, and the batch answer carries `Retry-After`.
- The `X-RateLimit-Remaining` header of the batch answer is what is left after the batch and all of its requests.

Example: one request, then three batches of 50 in the same minute. The first two batch answers carry `X-RateLimit-Remaining: 68` and `17`. The third:

```http
HTTP/1.1 200 OK
Content-Type: application/json
Retry-After: 56
X-Ratelimit-Limit: 120
X-Ratelimit-Remaining: 0
```

In its body the first 16 answers are `200`, and the last 34 are `{"code":429,"body":{"error":{"code":"rate_limited","message":"Too many requests with this API token. Wait a minute and try again, or send several requests in one batch."}}}`.

## Rules

- Not atomic: each request runs in its own transaction. If request 3 fails, requests 1 and 2 stay done.
- The requests run one after the other, in the given order, so a later request sees the changes of an earlier one.
- A batch can't contain `/public/v1/batch` or `/public/v1/openapi.yaml`, and the path must be a plain `/public/v1/` path (no host, no `..`):

```json
{"error":{"code":"validation","message":"Please check the highlighted fields.","fields":{"requests.0":"Use GET, POST, PUT, PATCH or DELETE on a /public/v1/ path (not the batch itself)."}}}
```

- The batch request's `Accept-Language` applies to every request in it.
- The 1 MB body limit applies to the whole batch, so a bulk import belongs in [its own endpoint](https://docs.uptools.io/echo/developer-guide/import/).
