# Errors, limits and headers

> The error shape every Reggio Digital API call shares, the status codes, rate limits, idempotency keys, response headers and the versioning promise.

Source: https://reggiodigital.com/developers/conventions

## Requests

- Send JSON with `Content-Type: application/json`, and ask for JSON back with `Accept: application/json`.
- Call us over `https`.
- Times are ISO 8601 with a timezone, for example `2026-10-05T09:00:00-04:00`. Times we send back are in UTC.
- An id in a path is used exactly as we gave it to you. An email address in a path is URL encoded, so `sam@example.com` becomes `sam%40example.com`.

## Errors

Every error we return from `/api/v1` has the same shape, so your code only needs one handler:

```json
{
  "error": {
    "type": "invalid_request",
    "message": "The to field must be an array.",
    "fields": {
      "to": ["The to field must be an array."]
    },
    "request_id": "0f5b6c1e-2d3a-4b8c-9e7f-1a2b3c4d5e6f"
  }
}
```

- `type` is stable and safe to branch on. `message` is written for a person and can change wording, so log it rather than matching on it.
- `fields` appears when a rule on a named field failed, one entry per field. In a batch the name includes the position, as in `emails.3.to`.
- `request_id` identifies the call. See [finding a call again](#finding-a-call-again).

| Status | error.type | When |
| --- | --- | --- |
| 401 | unauthorized | No key was sent, it is not a Reggio key, or it has been revoked. |
| 402 | no_plan | The account does not have the product this call belongs to. |
| 403 | forbidden | The key does not have the ability this call needs. |
| 403 | send_rejected | The from address is on a domain this key or account cannot send from. |
| 404 | not_found | No message, list or contact with that id on this account. |
| 409 | send_rejected | The sending domain has not finished verifying, or sending from it is stopped. |
| 409 | idempotency_conflict | The Idempotency-Key was already used for a different request. |
| 409 | not_cancellable | The message has already gone out, so it cannot be cancelled. |
| 409 | not_reschedulable | The message has already gone out, so its time cannot change. |
| 413 | send_rejected | The attachments add up to more than 28MB. |
| 422 | invalid_request | A field is missing or malformed. error.fields names each one. |
| 422 | send_rejected | The request is well formed but cannot be sent: no sending domain is set up for the from address, or every recipient is suppressed. |
| 429 | rate_limited | More than 120 requests in a minute. Wait for Retry-After seconds. |
| 429 | send_rejected | The account has reached its sending ceiling for now. |

A `500`, `502` or `503` means something went wrong on our side or with the mail network, and a `500` may arrive without the body above. Retry those with backoff. With an `Idempotency-Key`, a retry of a request that did go through gets the original answer instead of a second send.

## Rate limits

Each account can make 120 requests a minute across the email and newsletter APIs together. Every key on the account shares that budget.

Past the limit you get a `429` with the type `rate_limited` and a `Retry-After` header saying how many seconds to wait. Successful responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`, so a busy integration can slow down before it hits the ceiling.

If you need to send a large number of similar messages, use [a batch](https://reggiodigital.com/developers/email#sending-a-batch): up to 100 messages count as one request.

## Idempotency

Networks drop. When your code retries a request it is not sure went through, send an `Idempotency-Key` header with a value unique to that one piece of work, such as an order number:

```
Idempotency-Key: receipt-1042
```

For 24 hours, the same key with the same request body gets the original answer back, marked with `Idempotent-Replayed: true`, and nothing is sent or added twice. The same key with a different body gets a `409` with `idempotency_conflict`, because that is almost always a bug in the caller. Keys are at most 256 characters and are scoped to the API key that sent them.

Idempotency keys work on `POST /emails`, `POST /emails/batch` and `POST /newsletter/contacts`.

## Response headers

| Header | What it tells you |
| --- | --- |
| X-Reggio-Request-Id | A unique id for this call. Also inside error.request_id on a failure. Quote it to support. |
| X-Reggio-Monthly-Quota | Your monthly email allowance. On email sends only. |
| X-Reggio-Monthly-Remaining | How much of that allowance is left this month. On email sends only. |
| Idempotent-Replayed | Set to true when the answer is a replay of an earlier request with the same Idempotency-Key. |
| X-RateLimit-Limit | Requests allowed per minute for your account. |
| X-RateLimit-Remaining | Requests left in the current minute. |
| Retry-After | On a 429, the number of seconds to wait before trying again. |

## Finding a call again

Every response carries `X-Reggio-Request-Id`, and every error repeats it as `error.request_id`. Keep it in your logs.

Paste one into the [API log](https://reggiodigital.com/email/api/log) in the dashboard and you get that call back: the address you called, the status we answered with, and the fields we could not accept. It works for calls that never became a message, which is exactly when you need it. We keep this for 30 days. We never keep request bodies, attachments or keys in it.

If you contact support about a call, quoting the request id is the fastest way for us to find it.

## Versioning

Everything in these docs is under `/api/v1`. Within `v1`:

- we can add endpoints, optional request fields, response fields, headers, error types and webhook event types;
- we will not remove or rename a field, change its type, or make an optional field required.

Write code that ignores fields it does not recognise and treats an unknown error type like a generic failure for its status code. A change that would break that code ships under a new version instead. Anything an integration would notice is in the [changelog](https://reggiodigital.com/developers/changelog).
