Integrate
The fail-closed client contract
A verdict only ever arrives in a 200. Everything else — every error, every timeout, every outage on either side — is HOLD, and enforcing that is your client's job as much as ours.
This is the page that decides whether the integration is worth having. Every other page in these docs assumes you have implemented what is on it.
The rule fits in one sentence.
A verdict only ever arrives in a 200 response body. Any 5xx, any connection error, any TLS or DNS failure, and any timeout on your side means no verdict exists — and your client must apply
HOLD.
Two failure surfaces, only one of which is ours
There are two ways a deposit decision can go wrong, and they are handled in completely different places.
Degradation inside a decision. A vendor timed out, a budget is spent, a list is stale, a registry entry has not been re-verified, an authority in scope is not observable, the decision itself ran out of time. All of these still produce a decision. They come back as HTTP 200 with verdict: HOLD and a reason code. You do not have to detect them; they are already the answer.
| Condition | Verdict | Reason code | HTTP |
|---|---|---|---|
| Vendor timeout or vendor unavailable | HOLD | SCREENING_DEGRADED | 200 |
| Customer daily budget exhausted | HOLD | BUDGET_EXHAUSTED | 200 |
| A required list is older than its freshness objective | HOLD | LIST_STALE | 200 |
| A registry entry has not been re-verified in time | HOLD | REGISTRY_ENTRY_STALE | 200 |
| An authority in scope for this deposit is not observable | HOLD | COVERAGE_BLIND_SPOT | 200 |
| Decision could not complete within its latency budget | HOLD | DECISION_TIMEOUT | 200 |
No decision at all. We are unavailable, the request never reached us, or the response never reached you. There is no verdict to read. This is the case that lives in your code, and no contract we write can enforce it for you.
| What happened | What arrives | What you must do |
|---|---|---|
500 internal error | Problem, fail_closed_action: HOLD | Hold. |
503 unavailable | Problem, fail_closed_action: HOLD | Hold. |
504 gateway timeout | Problem, fail_closed_action: HOLD | Hold. |
429 rate limited | Problem, fail_closed_action: HOLD | Hold. Back off. |
400, 401, 403, 409 | Problem | Hold. These are bugs or entitlement problems, not verdicts. |
| Your client timed out | nothing | Hold. |
| Connection refused, TLS failure, DNS failure | nothing | Hold. |
| A 200 whose body does not parse | garbage | Hold. |
fail_closed_action is a machine-readable restatement of that obligation, not a verdict. Its only defined value is HOLD. Its absence — for example on a transport failure, where there is no response at all — changes nothing.
Why the outage case is a 5xx and not a 200
We could have returned 200 with verdict: HOLD when our own side is down. We deliberately do not.
A client that ignores status codes and reads only the body would then be unable to distinguish an outage from a decision. Making the outage a 5xx means a naive client fails loudly instead of quietly treating our silence as a considered answer.
What the official SDKs do
The official SDKs implement 5xx-as-HOLD as their default behaviour, and it cannot be disabled by configuration. There is no flag, no constructor option, and no environment variable that turns it off. If you need different behaviour, you are outside the supported integration, and the reason there is no switch is that a switch is exactly the thing that gets flipped at 3am during an incident.
Client-side timeouts, connection errors, and unparseable bodies are handled the same way, in the same place.
If you write your own client
Implement these branches. All of them.
function decide(deposit):
try:
r := POST /v1/decide/deposit
body: deposit
headers: Idempotency-Key = stable_key_for(deposit.deposit_ref)
timeout: your_budget
catch timeout, connection_error, tls_error, dns_error:
return HOLD # no verdict exists
if r.status != 200:
return HOLD # includes 4xx: not a verdict either
body := parse_json(r)
if parse failed or body.verdict not in {CREDIT, HOLD, REJECT}:
return HOLD # unknown shape is not permission
if r.header["Foreshock-Coverage-Health"] == "degraded":
record_blind_spot(deposit) # the verdict still stands; log it
return body.verdict
Note what the function never does: it has no path that returns CREDIT without a parsed 200 body that says CREDIT. Write the function so that CREDIT is producible from exactly one line of it. Then the review question — can this branch return CREDIT? — has an answer you can read off the page.
Anti-patterns
Each of these has been built by someone, in production, on purpose.
- Retry until 2xx, then credit. Retrying is correct. Crediting because the retries finally stopped failing, without reading the verdict in the body, is not.
- Credit on timeout because “it is probably fine”. A timeout is the single most likely shape of a real incident.
- A local allow rule for when Foreshock is down. This is fail-open with extra steps. It converts our outage into your loss.
- Treating a 4xx as a verdict. A
403is not aREJECTand a400is not aCREDIT. Neither carries a verdict. - Treating
REVOKEDas a clearance. It states that a match left your active set, nothing more. - Treating
STALEas safe. It states that the data can no longer be trusted. Fall back to something more conservative. - Ignoring
Foreshock-Coverage-Health: degraded. The verdict is still valid, but you are being told your visibility is reduced. Record it. - Crediting before the decision returns and reversing later. Reversal is not a control. It is an apology.
Testing every degradation mode
You cannot verify fail-closed behaviour against a healthy service. Point your client at a stub you control and drive the whole matrix. Contract tests and stubs can be generated from api/openapi.yaml, so the fixtures stay correct when the contract moves.
| # | Injected condition | Expected client behaviour |
|---|---|---|
| 1 | 200, verdict: CREDIT, no degradation | Credits. The only row that does. |
| 2 | 200, verdict: HOLD, SCREENING_DEGRADED | Holds. Schedules a re-decide. |
| 3 | 200, verdict: HOLD, BUDGET_EXHAUSTED | Holds. Alerts on budget. |
| 4 | 200, verdict: HOLD, LIST_STALE | Holds. |
| 5 | 200, verdict: HOLD, REGISTRY_ENTRY_STALE | Holds. |
| 6 | 200, verdict: HOLD, COVERAGE_BLIND_SPOT | Holds. Records the blind spot. |
| 7 | 200, verdict: HOLD, DECISION_TIMEOUT | Holds. |
| 8 | 200, verdict: REJECT | Does not credit. Routes per pool. |
| 9 | 200, unrecognised reason code | Holds, at least as severely as risk_level. |
| 10 | 200, Foreshock-Coverage-Health: degraded | Acts on the verdict, records reduced visibility. |
| 11 | 400 / 401 / 403 / 409 | Holds. Alerts. Does not retry blindly. |
| 12 | 429 with Retry-After | Holds. Backs off. Does not credit while waiting. |
| 13 | 500 / 503 / 504 | Holds. Retries with the same idempotency key. |
| 14 | Response withheld past your timeout | Holds. |
| 15 | Connection refused | Holds. |
| 16 | TLS handshake failure | Holds. |
| 17 | DNS resolution failure | Holds. |
| 18 | 200 with a truncated or malformed body | Holds. |
| 19 | 200 with verdict: "MAYBE" | Holds. |
| 20 | Two calls with the same key and the same body | One decision. Idempotency-Replayed: true on the second. |
Then write the assertion that matters more than any individual row:
For every row except the first, assert that the deposit was not credited.
Run it in CI. An exhaustive branch test on the credit path is not optional coverage; it is the only thing standing between a bad afternoon and a bad quarter. Any new error branch you add later needs its own row, asserting HOLD.
What this buys you
When we are down, you stop crediting. That is a cost, and it is the cost you chose when you put a gate in front of the credit path.
The alternative — crediting through an outage — is not a smaller cost. It is a cost you find out about later, denominated in funds that have already left.