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 always retry, and 409 is the one where it depends on why you got it. 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.

Conflicts on write routes

A 409 Conflict on a write route is almost always about the idempotencyKey: you sent one we have seen before, attached to something other than the request in front of us. Donation pledges and grant submissions are the routes that carry a key, so they are the routes that produce this. Exactly one cause is retryable. A creation already in progress means a concurrent request holding the same idempotencyKey has not finished yet, so there is no outcome to give you yet. Send the same request again, same key and same body, after a short delay, and you will get the outcome once the first one lands. This is the only conflict where replaying the unchanged request is the action that resolves it. Everything else is terminal. The request as written will never succeed, no matter how many times you send it:
  • The key is already attached to a different partnerOperationId.
  • The key is already attached to a different partner, or to a different acting user.
  • The key is already attached to a different kind of pledge. A cash key replayed against the stock route, for example.
  • The partnerOperationId has already been recorded.
  • The key is attached to a settled pledge, and the settled values in this request do not match the ones we recorded.
Nothing in a 409 tells you which conflict you hit. There is no machine-readable conflict reason today. Whatever message the body carries is diagnostic text for humans and logs: it varies by cause, it is not part of our contract, and it is not a branch target. You do not need to tell them apart to behave correctly, because an identical replay never changes anything. If the original request succeeded and you replay within a day, the replay hands that original record back: 201 on the settled pledge routes, 200 on grant submissions. After about a day, an identical replay of a settled pledge answers 409 instead of returning the record, and says so: the pledge is recorded and no action is needed. Either way nothing is created and nothing is amended. If the conflict is terminal, you get the same 409 and are no worse off. So the right automatic behavior for a 409 is a small, bounded number of identical retries, same key and same body, and then stop, log the message, and put it in front of a person. An in-progress conflict clears inside that window. A terminal one never will, and it is a human who needs to know why.

Settled pledges cannot be amended

The settled pledge routes record money that has already moved. That makes a replay a way to confirm what we recorded, never a way to change it. Send a settled pledge again under the same idempotencyKey with different settled values and you get a 409. Your new values are not applied and the original record is untouched. On top of the bindings above, these are the settled values we compare: A replay that matches on all of them still returns 201 with the original record, so ordinary retries are unaffected. That includes a retry that sends the fund id or the ticker in different letter case, which we compare case-insensitively, and one that varies the case of the idempotencyKey itself.
Do not answer this 409 with a new idempotencyKey. It is the obvious next move, since the old key is taken, but it does not get your correction in. With the same partnerOperationId you get another 409, because that is the pair we record a settled operation against. Change the partnerOperationId as well and the write does succeed, as a second, separate donation: the first record is still there and still counted, and you have now double-counted real money that only moved once.A 409 on a corrected amount means the correction did not land, and it cannot land through the API. Contact us and we will correct the recorded pledge.

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 terminal conflict. A 409 that keeps returning 409 on identical replay, asserting you stop after a bounded number of attempts and escalate, rather than retrying forever or reissuing the request under a fresh idempotencyKey.
  • 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.