Skip to main content
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:
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:
A fault we detected and raised deliberately — a server-side misconfiguration, for example — carries its own message instead:
There is no Retry-After on a 500. Use your own backoff schedule.
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.

Retrying

Retryable and non-retryable, precisely: 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. Generate it once per user action, and reuse it for every retry of that action.

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. 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.
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.

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 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:
And the maintenance case, which is the same status with none of the same affordances:
Points worth covering in your own tests:
  • Backoff and exhaustion — repeated 503s 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.
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.