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 response
{
  "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

Error codes
HTTPcodeMeaningWhat to do
401unauthorizedMissing, 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.
401api_key_expiredThe API key has expired.Issue a new key.
401api_key_revokedThe API key was revoked.Issue a new key.
401invalid_tokenAn OAuth access token that was real but is expired or revoked.Refresh once and retry once; if refresh fails with invalid_grant, re-consent.
403insufficient_scopeThe OAuth token lacks the route's scope (details[0].reason names it).Request the scope at consent.
404not_foundNo such resource, or it belongs to another project (never 403).Check the id and the credential's project.
404domain_claimedThe domain is not available to add. Whether and by whom it is held is deliberately not disclosed.Use a domain you control.
409tenant_suspendedThe tenant is suspended and cannot send.Reinstate it with PATCH /v1/tenants/{id}.
409tenant_not_sendableThe tenant is provisioning or provision_failed.Read the tenant until it is active.
409idempotency_conflictThe idempotencyKey was already used with a different request.Use a new key for a different request.
409idempotency_in_progressA request with this key has not finished; nothing accepted yet.Retry shortly with the same key.
409idempotency_key_abandonedA request with this key died; the reservation has been cleared.Retry with the same key.
409suppression_renewedA bounce or complaint re-suppressed the address while your release was in flight.Check the suppression list before retrying.
413payload_too_largeThe request is too large.Send less.
422validation_failedA field is missing or invalid, or an unknown query parameter was sent (details[] lists them).Fix the request. Do not retry unchanged.
422unsupported_fieldcc, bcc, attachments, or an array in to: not supported in this version.Remove the field.
422domain_not_verifiedThe from domain is not verified for this tenant (details[0].reason: domain_not_verified:<domain>).Publish the DNS records and wait for verified.
429rate_limitedA per-tenant or organisation-plan send limit was hit (details[0].reason names the window).Wait Retry-After seconds.
500internal_errorSomething unexpected on our side.Retry with backoff, same idempotencyKey; quote requestId if it persists.
503provider_unavailableThe email provider could not be reached.Retry with backoff.
503capacity_exhaustedThe 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.
  • held is 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: pending or approved, with when and by whom) and heldEmailCount, 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):

Send limit sources
SourceWindow names (details[0].reason)Whose problem
Per tenant: isolates your customers from each otherburst_per_second, per_hour, per_dayOne 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 ownsorganization_burst_per_second, organization_per_hour, organization_per_dayThe 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).

429 rate_limited
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-After is in seconds. details[0] is { "field": "window", "reason": "<window name>" }; the organization_ 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 idempotencyKey so retries cannot double-send.
Honouring Retry-After (Node.js)
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

OAuth limits
LimitValue
/oauth/token and /oauth/revoke, per client_id60 per minute, shared by both, fixed one-minute window
Failed client authentication, per IP30 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.