Skip to main content

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​

LayerMechanism
1 · Batch content hashWe 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 sourceLoanNumberA 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​

BoundaryMechanism
Your recallsPOST /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 webhooksAt-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 as exhausted and 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/updates serves 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.

  • sequence counts 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 sequence starting at 1 again — key your watermark on (sourceLoanNumber, caseId), or reset it when you see case.received.
  • Apply any event whose sequence is greater than your watermark for that case; on a gap you may reconcile with GET /cases/{loan} for the authoritative snapshot.
  • Never skip a payment.collected because of ordering — book it (it is idempotent on eventId / reference) and gate only your case-state fields.
  • payment.collected carries absolute values for the case (totalCollected, outstandingAfter) alongside the incremental amount — 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. :::