Foreshock

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.

Updated
2026-08-24

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.

ConditionVerdictReason codeHTTP
Vendor timeout or vendor unavailableHOLDSCREENING_DEGRADED200
Customer daily budget exhaustedHOLDBUDGET_EXHAUSTED200
A required list is older than its freshness objectiveHOLDLIST_STALE200
A registry entry has not been re-verified in timeHOLDREGISTRY_ENTRY_STALE200
An authority in scope for this deposit is not observableHOLDCOVERAGE_BLIND_SPOT200
Decision could not complete within its latency budgetHOLDDECISION_TIMEOUT200

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 happenedWhat arrivesWhat you must do
500 internal errorProblem, fail_closed_action: HOLDHold.
503 unavailableProblem, fail_closed_action: HOLDHold.
504 gateway timeoutProblem, fail_closed_action: HOLDHold.
429 rate limitedProblem, fail_closed_action: HOLDHold. Back off.
400, 401, 403, 409ProblemHold. These are bugs or entitlement problems, not verdicts.
Your client timed outnothingHold.
Connection refused, TLS failure, DNS failurenothingHold.
A 200 whose body does not parsegarbageHold.

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 403 is not a REJECT and a 400 is not a CREDIT. Neither carries a verdict.
  • Treating REVOKED as a clearance. It states that a match left your active set, nothing more.
  • Treating STALE as 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 conditionExpected client behaviour
1200, verdict: CREDIT, no degradationCredits. The only row that does.
2200, verdict: HOLD, SCREENING_DEGRADEDHolds. Schedules a re-decide.
3200, verdict: HOLD, BUDGET_EXHAUSTEDHolds. Alerts on budget.
4200, verdict: HOLD, LIST_STALEHolds.
5200, verdict: HOLD, REGISTRY_ENTRY_STALEHolds.
6200, verdict: HOLD, COVERAGE_BLIND_SPOTHolds. Records the blind spot.
7200, verdict: HOLD, DECISION_TIMEOUTHolds.
8200, verdict: REJECTDoes not credit. Routes per pool.
9200, unrecognised reason codeHolds, at least as severely as risk_level.
10200, Foreshock-Coverage-Health: degradedActs on the verdict, records reduced visibility.
11400 / 401 / 403 / 409Holds. Alerts. Does not retry blindly.
12429 with Retry-AfterHolds. Backs off. Does not credit while waiting.
13500 / 503 / 504Holds. Retries with the same idempotency key.
14Response withheld past your timeoutHolds.
15Connection refusedHolds.
16TLS handshake failureHolds.
17DNS resolution failureHolds.
18200 with a truncated or malformed bodyHolds.
19200 with verdict: "MAYBE"Holds.
20Two calls with the same key and the same bodyOne 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.