Engineering write-up · 5 min read

Webhooks that arrive late, twice or out of order.

Polling is simple and wasteful. Webhooks are efficient and messy: once you push changes to another system, deliveries arrive late, they arrive twice, and they arrive out of order.

By Ahmed Farid, senior software engineer ·

This is the sync behind Recovery Advisers, a debt-recovery and case-management platform in Dubai. An internal app runs the whole lifecycle, from lead to case, mandate, litigation, recovery and invoice. Creditors and law firms follow their cases in a separate client portal, which is a read-only mirror of the main app.

From polling to push

The first version of any mirror polls: every few minutes, ask the main system what changed. It is easy to build and wasteful to run, and clients still see updates late. Recovery Advisers replaced polling with webhook push: when a case changes, the main app sends the change to the client environment as an event.

Push is the right call, but it comes with three facts of life. Deliveries can be late (a retry after a timeout), duplicated (the sender retried, but the first attempt had already arrived) and out of order (two quick changes, and the second one lands first).

Why "apply the latest event" moves cases backwards

Picture a case that goes from mandate to litigation a few seconds later. Two events are sent. The litigation event arrives first and the portal shows litigation. Then the delayed mandate event arrives, and a handler that simply applies whatever it receives moves the case back to mandate. The client now sees their case going backwards, and nothing in the logs looks like an error.

Per-record sequence numbers

The fix on Recovery Advisers is a sequence number per case. Every change to a case increments its sequence, and every event carries it. The receiving side applies an event only if its sequence is higher than the one it already holds. A late event is older than what is stored, so it is ignored; a duplicate carries the same number, so it is ignored too. A mirrored case never moves backwards.

The check and the write should be a single statement, so two deliveries handled at the same moment cannot both pass the check. A simplified version of the receiving side in Laravel:

// Apply an event only if it is newer than what the mirror holds.
$applied = DB::table('portal_cases')
    ->where('case_id', $event['case_id'])
    ->where('seq', '<', $event['seq'])
    ->update([
        'status'     => $event['status'],
        'payload'    => json_encode($event['case']),
        'seq'        => $event['seq'],
        'updated_at' => now(),
    ]);

// 0 rows: stale or duplicate. Acknowledge it all the same,
// so the sender stops retrying something already handled.
return response()->noContent();

(A case the mirror has never seen needs an insert as well; that path follows the same rule.)

Practices that make this hold up

The mirror only shows what each client may see

Syncing data out of a system is also a decision about who sees it. On Recovery Advisers, role-based access is enforced in the data layer, not in the UI, and tenant-resolve middleware isolates every tenant: a creditor or a law firm only ever sees its own cases. Staff sign in with their company Microsoft accounts through Entra ID single sign-on.

Rebuilt without stopping the business

The platform was rebuilt while the team kept using it every day. That is mostly a matter of order: ship in small slices behind the workflow people already use, move one part of the lifecycle at a time, and keep the old path working until the new one has carried real traffic. The result, in the client's words, is that clients now check their own case status instead of emailing for it.