Errors and rate limits
Every non-2xx response on /v1 has the same shape. Branch on error.code, never on the message.
Error shape
{
"error": {
"code": "validation_failed", // stable, safe to branch on
"message": "Human-readable, may change",
"details": [{ "field": "to", "reason": "..." }], // optional
"requestId": "req_..." // quote this to support
}
}/oauth/* is the exception: it follows RFC 6749 ({ "error", "error_description" }). See OAuth errors.
Error codes on /v1
| HTTP | code | Meaning | What to do |
|---|---|---|---|
| 401 | unauthorized | Missing, malformed or unknown credential; a refresh token or session where an API credential belongs; or two credentials at once. | Fix the credential. Do not retry. |
| 401 | api_key_expired | The API key has expired. | Issue a new key. |
| 401 | api_key_revoked | The API key was revoked. | Issue a new key. |
| 401 | invalid_token | An OAuth access token that was real but is expired or revoked. | Refresh once and retry once; if refresh fails with invalid_grant, re-consent. |
| 403 | insufficient_scope | The OAuth token lacks the route's scope (details[0].reason names it). | Request the scope at consent. |
| 404 | not_found | No such resource, or it belongs to another project (never 403). | Check the id and the credential's project. |
| 404 | domain_claimed | The domain is not available to add. Whether and by whom it is held is deliberately not disclosed. | Use a domain you control. |
| 409 | tenant_suspended | The tenant is suspended and cannot send. | Reinstate it with PATCH /v1/tenants/{id}. |
| 409 | tenant_not_sendable | The tenant is provisioning or provision_failed. | Read the tenant until it is active. |
| 409 | idempotency_conflict | The idempotencyKey was already used with a different request. | Use a new key for a different request. |
| 409 | idempotency_in_progress | A request with this key has not finished; nothing accepted yet. | Retry shortly with the same key. |
| 409 | idempotency_key_abandoned | A request with this key died; the reservation has been cleared. | Retry with the same key. |
| 409 | suppression_renewed | A bounce or complaint re-suppressed the address while your release was in flight. | Check the suppression list before retrying. |
| 413 | payload_too_large | The request is too large. | Send less. |
| 422 | validation_failed | A field is missing or invalid, or an unknown query parameter was sent (details[] lists them). | Fix the request. Do not retry unchanged. |
| 422 | unsupported_field | cc, bcc, attachments, or an array in to: not supported in this version. | Remove the field. |
| 422 | domain_not_verified | The from domain is not verified for this tenant (details[0].reason: domain_not_verified:<domain>). | Publish the DNS records and wait for verified. |
| 429 | rate_limited | A per-tenant or organisation-plan send limit was hit (details[0].reason names the window). | Wait Retry-After seconds. |
| 500 | internal_error | Something unexpected on our side. | Retry with backoff, same idempotencyKey; quote requestId if it persists. |
| 503 | provider_unavailable | The email provider could not be reached. | Retry with backoff. |
| 503 | capacity_exhausted | The service is at capacity. | Retry with backoff. |
Not every route can return every code; the OpenAPI document lists the statuses per operation. A tenant or resource in another project is always 404.
Held sends (new organisations)
A new organisation's first sends are reviewed by a human (first-send approval). While the approval is pending, POST /v1/emails still answers 202, but the email's status is held instead of queued. This is expected, not an error: held emails are delivered once Reach approves the organisation.
- What to do: nothing in your request is wrong. Contact Reach to get approved, or wait. Do not resend held emails; you would create duplicates.
heldis one of the email statuses everywhere a status is listed or filterable:queued,held,dispatching,sent,dispatch_unconfirmed,delivered,bounced,complained,failed,suppressed,canceled. Handle it explicitly in your code.- Console side:
GET /account/organizations/:id/sending-policy(a console-session endpoint, not callable with an API key or OAuth token) returns the organisation's plan ceiling, the approval state (sendApproval.status:pendingorapproved, with when and by whom) andheldEmailCount, the number of emails waiting right now. Approval itself is an operator action; there is no integrator API for it.
Send rate limits
There are two independent sources of a send 429, both enforced on every POST /v1/emails, both with three windows (per-second burst, per hour, per day):
| Source | Window names (details[0].reason) | Whose problem |
|---|---|---|
| Per tenant: isolates your customers from each other | burst_per_second, per_hour, per_day | One tenant used its own budget. Its limits are limits on GET /v1/tenants/{id}, seeded from the service defaults and raisable per tenant by an operator. |
| Organisation plan ceiling: the total across every project and tenant the organisation owns | organization_burst_per_second, organization_per_hour, organization_per_day | The whole organisation is at its plan's ceiling. Adding tenants divides this allowance, it does not multiply it. A higher plan is the fix. The console reads it from the sending-policy endpoint (plan.limits). |
The free plan's ceiling is 200 per day, 20 per hour and 5 per second for the whole organisation (the service's configured default; your plan's actual numbers are in the console).
HTTP/1.1 429 Too Many Requests
Retry-After: 1
{
"error": {
"code": "rate_limited",
"message": "Too many emails: the "burst_per_second" rate limit was exceeded, try again later",
"details": [{ "field": "window", "reason": "burst_per_second" }],
"requestId": "req_..."
}
}Retry-Afteris in seconds.details[0]is{ "field": "window", "reason": "<window name>" }; theorganization_prefix is how you tell the plan ceiling from the per-tenant limit.- A rate limit never blocks a retry of an already-accepted
idempotencyKey. - Always send an
idempotencyKeyso retries cannot double-send.
async function sendWithRetry(body, attempt = 0) {
const res = await fetch("https://api.tryreach.email/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.REACH_API_KEY}`,
"Content-Type": "application/json",
},
// Same idempotencyKey on every attempt: a retry can never double-send.
body: JSON.stringify(body),
});
if (res.status === 429 && attempt < 5) {
const wait = Number(res.headers.get("retry-after") ?? 1);
await new Promise((r) => setTimeout(r, wait * 1000));
return sendWithRetry(body, attempt + 1);
}
return res;
}OAuth endpoint limits
| Limit | Value |
|---|---|
| /oauth/token and /oauth/revoke, per client_id | 60 per minute, shared by both, fixed one-minute window |
| Failed client authentication, per IP | 30 per minute; past it, every request from that IP gets 429 until the window ends |
The response is 429 slow_down with Retry-After. Honour it; do not loop.
failed. Inspect attempts with GET /v1/webhook-deliveries/{id}. See the quickstart.