> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endaoment.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> Server error shapes, retry guidance, and the production rate limit — plus how to exercise these paths from your own test suite.

Client errors are easy to trigger while you build: send a malformed body, a bad key, or a conflicting reference, and the API tells you what went wrong. The responses that mean *something went wrong on our side* are harder to plan for, because you cannot make our infrastructure fail on demand.

This page documents those responses, so you can build and test against them without waiting to encounter one.

## Server error shapes

### `503 Service Unavailable`

Our contract for **transient, retry later**. A `503` raised by the application carries a `Retry-After` header in seconds:

```http theme={null}
HTTP/1.1 503 Service Unavailable
Retry-After: 2
Content-Type: application/json

{
  "message": "<a description of what failed>",
  "error": "Service Unavailable",
  "statusCode": 503
}
```

**When `Retry-After` is present, it is the part of this response to trust.** Read your delay from it rather than hardcoding one, and let it **override** whatever your exponential backoff would have computed. The value is a server-side default that can change, and honoring it keeps your retry cadence aligned with what we are actually asking for.

**Not every `503` looks like this, so your retry path needs a default delay for when the header is absent.** A `503` served while the API is in maintenance, or returned by our edge or load balancer before the request reaches the application, carries neither the header nor this body — the body in that case is a short non-JSON string. Maintenance during a release window is the `503` you are most likely to meet in practice, so treat the header as an optimization over your own backoff rather than something to read unconditionally.

**The `message` is a human-readable string that varies by cause.** Different transient failures produce different text, and the wording is not part of our contract. The `error` field, by contrast, is the standard reason phrase for the status code — so it tells you nothing the status code did not already. Branch on the status code; log the message.

### `500 Internal Server Error`

A failure on our side. A `500` arrives in one of two shapes, and **you should not branch on which one you got.**

An unhandled fault produces a fixed two-key envelope:

```http theme={null}
HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{ "statusCode": 500, "message": "Internal server error" }
```

A fault we detected and raised deliberately — a server-side misconfiguration, for example — carries its own message instead:

```http theme={null}
HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{
  "message": "<a description of what failed>",
  "error": "Internal Server Error",
  "statusCode": 500
}
```

There is no `Retry-After` on a `500`. Use your own backoff schedule.

<Note>
  **Branch on the HTTP status line, not on the response body.** The status code is the one thing every error response on this page is guaranteed to carry. `statusCode` mirrors it when the body is JSON, but not every error has a JSON body — a maintenance `503`, and anything our edge returns rather than the application, does not. Parse the body only after you have decided what to do from the status code, and expect the parse to fail. `message` and `error` are for humans and logs.
</Note>

## Retrying

Retryable and non-retryable, precisely:

| Status       | Retry?                                                  | Delay to use                                                                                                                      |
| ------------ | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `500`        | Yes                                                     | Your own exponential backoff                                                                                                      |
| `502`, `504` | Yes                                                     | Your own exponential backoff. These come from our edge rather than the API, so they carry no guaranteed body — do not expect JSON |
| `503`        | Yes                                                     | **The `Retry-After` header** when present, which takes precedence over your computed backoff; otherwise your own backoff          |
| `429`        | Yes, but not soon — see [Rate limiting](#rate-limiting) | Wait out the full ban window, which is minutes rather than seconds                                                                |
| Other `4xx`  | **No**                                                  | Retrying unchanged will fail the same way; the request itself has to change                                                       |

`429` is the one `4xx` you should retry. Everything else in that range means we understood the request and rejected it on its merits.

**On write routes, always retry with the same `idempotencyKey`.** This matters more than it might appear. A `5xx` tells you the request failed *from your side of the connection*; it does not always tell you the work did not happen. A request can time out at the edge while processing continues, and a retry without the original key could produce a duplicate donation pledge or grant submission.

Donation pledges and grant submissions already require an `idempotencyKey` — see [Idempotency](./integration-patterns#idempotency). Generate it once per user action, and reuse it for every retry of that action.

```js theme={null}
import { randomUUID } from 'crypto';

// Generate once per user action — NOT once per attempt.
const idempotencyKey = randomUUID();

await retryWithBackoff(() =>
  submitGrant({ ...payload, idempotencyKey })
);
```

## Rate limiting

Endaoment enforces a rate limit at the network edge **in production only**. Lower environments have no rate limiter, so you will not encounter a `429` while building against dev or staging.

| Property               | Behavior                                                                            |
| ---------------------- | ----------------------------------------------------------------------------------- |
| Scope                  | **Per source IP** — not per partner, per key, or per account                        |
| Response when exceeded | `429 Too Many Requests`                                                             |
| Effect                 | A temporary **ban**, on the order of **ten minutes**, not a single rejected request |

**The ban is the part to design around.** Exceeding the limit does not merely fail the request that crossed it — matching traffic from your source IP is refused for the duration of the ban. A retry loop that responds to a `429` by retrying immediately will turn a brief burst into a multi-minute outage of your integration.

One consequence worth planning for: if your traffic egresses through shared infrastructure — a NAT gateway, a shared CI runner, a proxy — requests that are not yours can count against the same limit, so you may see a `429` well below the volume you thought you were sending.

Treat the per-IP scope as current behavior rather than a guarantee, and do not architect around it. If you need more headroom, the supported route is to ask us for it rather than to spread traffic across source addresses.

<Warning>
  **A `429` means the ban is already in effect, not that a single request was rejected.** The threshold that returns the `429` is the same threshold that starts the ban, so requests you send during that window will keep failing. Wait out the full interval before your next attempt — retrying in seconds accomplishes nothing.

  Do not assume the `429` carries a `Retry-After` header either. It is generated at our network edge rather than by the API, so do not depend on a retry hint being present.
</Warning>

### Staying under it

The limit is sized for the steady traffic of a live integration, not for bulk work. Spread bulk operations — backfills, reconciliation sweeps, batch imports — over time rather than issuing them as fast as your client allows, and prefer a paced worker with a fixed delay between requests over an unbounded concurrent fan-out.

**Before any bulk operation, [ask us](https://discord.com/channels/734855436276334746/890622199390699580) for a workload-specific limit.** Tell us the volume and the window you need it in, and we can confirm a safe rate or raise the limit for that work. This is a much better conversation to have before a backfill starts than after a ban.

We deliberately do not publish the exact threshold here. It is tuned operationally and can change, and a number pinned in documentation is one partners keep building against after it has stopped being true.

## Testing your error handling

You do not need us to fail in order to test how you handle failure. The response shapes above are documented precisely for this reason, so the most reliable way to exercise your retry, backoff, and alerting paths is to serve them yourself from a local stub or intercepting proxy.

A minimal example — the `503` contract, using [MSW](https://mswjs.io/):

```ts theme={null}
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.post('*/v1/transfers/partner/grant-submissions', () =>
    HttpResponse.json(
      {
        message: 'Simulated transient failure',
        error: 'Service Unavailable',
        statusCode: 503,
      },
      { status: 503, headers: { 'Retry-After': '2' } }
    )
  ),
];
```

And the maintenance case, which is the same status with none of the same affordances:

```ts theme={null}
http.post('*/v1/transfers/partner/grant-submissions', () =>
  new HttpResponse('API is Down for Maintenance', { status: 503 })
),
```

Points worth covering in your own tests:

* **Backoff and exhaustion** — repeated `503`s until your retry budget runs out.
* **Recovery** — a `503` on the first attempt and success on the second. This is the branch most likely to be wrong, and the one a "always fails" stub will not exercise.
* **Idempotent replay** — the same `idempotencyKey` on the retry, asserting you do not create a duplicate.
* **`429` handling** — a `429` with no `Retry-After`, asserting you wait minutes rather than retrying immediately or giving up as though it were an ordinary `4xx`.
* **A non-JSON error body** — a `503` with no `Retry-After` and a plain string body, asserting you still fall back to your own backoff. A client that calls `response.json()` before checking the status throws here, and the resulting error looks like a bug in your integration rather than an outage on our side.

<Note>
  If your integration needs to exercise a failure mode that is not covered here, or you want to validate against a real Endaoment environment rather than a stub, reach out through your support ticket or [open a new one](https://discord.com/channels/734855436276334746/890622199390699580).
</Note>
