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:
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:
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 a429 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.
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 — the503 contract, using MSW:
- Backoff and exhaustion — repeated
503s until your retry budget runs out. - Recovery — a
503on 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
idempotencyKeyon the retry, asserting you do not create a duplicate. 429handling — a429with noRetry-After, asserting you wait minutes rather than retrying immediately or giving up as though it were an ordinary4xx.- A non-JSON error body — a
503with noRetry-Afterand a plain string body, asserting you still fall back to your own backoff. A client that callsresponse.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.