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 no-op — 200 with idempotentReplay: true and the original summary, no header needed — as long as every loan in it still has a live case. If any loan in the batch has no live case (it was collected, closed or recalled, or its row was rejected and it never got one), the batch is processed normally: those loans get a fresh case (the re-default cycle) and the rest go through the normal update path (identical rows count as unchanged). Your coordinate grace period is part of the batch identity: a batch committed under the strict address rules and re-sent unchanged after a grace period starts (or the other way round) is processed again, not replayed.
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. A case that Fieldproof re-opens and that later closes again emits a new case.closed (its own eventId, a higher sequence) when it closes a different way, or when any other event was recorded on the case since the last closure. Re-opening itself emits no event, so a closure that repeats the previous one with nothing in between is treated as a retry and not sent again.

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. Rows in the delivery log are kept for 30 days, but POST /webhooks/deliveries/{eventId}/replay rebuilds the delivery from the event store, which keeps events for about 13 months — so replay works for that long (it needs an active callback). 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. :::