Idempotency & reliability
Three promises, and what backs each one.
1. Nothing is applied twice
Every boundary where a message can be repeated has an idempotency rule. On our
side these are automatic; on yours there is exactly one thing to build (dedupe
webhooks on eventId).
Pushing cases — three layers
| Layer | Mechanism |
|---|---|
| 1 · Batch content hash | We content-hash every committed batch. Re-sending an identical batch is a guaranteed no-op: 200 with idempotentReplay: true and the original summary. No header needed. |
2 · Idempotency-Key header (network retry) | Optional header on POST /cases: per organisation, 24 h TTL, only 200 responses cached, ignored on ?dryRun=1, no payload comparison. A replay with the same key returns the original response body verbatim plus the header X-Idempotent-Replay: true — detect a replay by the header, not by the body. Replays count toward the 10 batches/hour limit. |
3 · Row upsert on sourceLoanNumber | A row for a loan that already has an active case updates that case in place — it never opens a second one. We hold at most one active case per loan number. Only when the previous case is closed does a re-push open a fresh case (new caseId, sequence back to 1). |
Layers 1 and 2 make retries free; layer 3 makes them harmless even when the retry carries new data.
The other boundaries
| Boundary | Mechanism |
|---|---|
| Your recalls | POST /cases/{loan}/recall is idempotent — a second call (or a recall of an already-terminal case) returns alreadyClosed: true with the current status and emits nothing. |
Your payment confirms (org_gateway mode — your gateway) | Unique on (loan, reference). A replay gets 409 DUPLICATE_CONFIRMATION with error.caseId — treat it as success (only the caseId comes back, not the original booking). A 5xx after our ledger row was written is safe to retry: the retry resumes and completes. |
| Our webhooks | At-least-once delivery — retries and manual replays are normal, not errors. You dedupe on eventId. |
2. Nothing is lost
Every business event is written durably right after the case change it
describes. This is not one database transaction: a crash in the tiny window
between the two could, very rarely, lose an event — which is why the daily
fullSnapshot push plus GET /cases/{loan} reconcile exist as the
authoritative backstop.
From the event store, two independent paths reach you:
- Webhooks — POSTed to your callback with the retry ladder
0s → 30s → 2m → 10m → 1h → 6h → 24h(7 attempts, ≈ 31 h). After that the delivery parks asexhaustedand stays replayable for 30 days through the delivery log. A circuit breaker (20 consecutive failures) suspends deliveries to a dead endpoint, probes it every 15 minutes, auto-recovers and drains the backlog oldest-first. - The poll feed —
GET /cases/updatesserves the same envelopes, oldest-first, for all event types regardless of your webhook subscription (poll API). Events emitted while you had no callback configured are in the poll feed and can be replayed to your callback once one is configured.
:::note Monitoring is yours
The platform does not send alerts to your technical contact when a key locks,
a delivery exhausts or the breaker opens. Watch GET /webhooks/deliveries and
GET /health from your own monitoring.
:::
3. Order is recoverable, not guaranteed
Retries and concurrent delivery (up to 25 POSTs in flight, not strictly
serialised per case) make in-order arrival impossible, so every event carries
a per-case, monotonic sequence — starting at 1 for every new case.
sequencecounts all event types, including ones you are not subscribed to, so default subscribers legitimately see gaps (1 → 4). Gaps are normal — never wait for a gap to fill.- A loan whose case closed and is pushed again gets a new case with
sequencestarting at 1 again — key your watermark on(sourceLoanNumber, caseId), or reset it when you seecase.received. - Apply any event whose
sequenceis greater than your watermark for that case; on a gap you may reconcile withGET /cases/{loan}for the authoritative snapshot. - Never skip a
payment.collectedbecause of ordering — book it (it is idempotent oneventId/reference) and gate only your case-state fields. payment.collectedcarries absolute values for the case (totalCollected,outstandingAfter) alongside the incrementalamount— a missed intermediate event can never corrupt your books.
The full receiver algorithm, with code, is on updating your LMS.
:::info Seen in practice
Under concurrent delivery a case.closed (seq 4) can genuinely arrive before
the payment.collected (seq 2–3) that caused it. A sequence-aware receiver
handles this without special cases — the terminal event's totalCollected is
already correct, and the late payment is still booked on its own eventId.
:::