Results
| Metric | Before | After | Window and source |
|---|---|---|---|
| Hours reclaimed across the team | 0 | ~30 hours / week | Across the brokerage's agent and admin headcount · measured as below |
| Manual copy-paste steps between tools | Fully manual, multi-tool handoff on every transaction | 0 | Process map, before and after |
| Transaction status visibility | Per-agent, in each agent's own system | Single live status per transaction | Pipeline dashboard, all offices |
Events processed and failed-events volume are not in this table. They live in the event store and dead-letter queue and we have not yet pulled and dated an export for this page — they will be added once sourced, not shown as a placeholder or estimated.
How the 30 hours was measured. Not a survey, and not a vendor's saved-time calculator. Two steps:
1. Time-and-motion on the mapped steps. In the Blueprint phase we timed each manual handoff step with the people who actually do it — three observed repetitions per step, median taken, across the four highest-frequency transaction types.
2. Multiplied by observed frequency. Step time × the actual count of that step per month, taken from the CRM and e-sign systems, not from estimates.
Reclaimed hours = the sum of the median times for the steps that no longer exist. The tilde is doing real work in "~30 hours": this is a median-times-frequency figure with a stated method, not a stopwatch on every agent. We would rather publish the method and let you discount it than publish a precise-looking number with no method at all.
What we are not claiming. No revenue figure. No claim about deals closed, commission, or listing volume. Thirty hours reclaimed is not thirty hours of new selling — some of it is people going home on time, and that is a legitimate outcome we are not going to dress up as pipeline.
The question this engagement had to answer
*Why does a brokerage need three people to know whether a deal is actually progressing?*
The brokerage was not short of software. It had a CRM, portal feeds, an e-signature tool, an accounting package and WhatsApp, and every one of them worked. The failure was between them. State lived in five places, drifted, and the only reconciliation mechanism was a person retyping — which meant the true status of any transaction was whatever the most recent human remembered to update.
What was hard
Retail-grade tools with no transactional guarantees. These are consumer and SMB APIs. They rate-limit unpredictably, deliver webhooks twice, occasionally deliver them out of order, and sometimes not at all. Any automation that assumes exactly-once delivery across that surface will corrupt data within a week.
WhatsApp is a regulated channel, not a message queue. Business-initiated messages outside the 24-hour customer service window require pre-approved templates. You cannot free-text a client at 9pm because a milestone fired.
The rejected alternative. The brokerage wanted a no-code automation builder — one scenario per integration. We rejected it. Point-to-point scenarios between five systems is a graph, not a list: every new tool multiplies the connections, no scenario knows what another has done, and there is no shared idea of what a transaction *is*. It demos in a day and becomes unmaintainable in a quarter. We built a single event spine instead. It took longer and it is why the failure count is small enough to fit in a table.
What broke. A portal webhook fired twice for one price change. Our first version processed both, sent two WhatsApp notifications to the same client, and posted two CRM activity entries. Nobody was harmed and it was fixed in a day — but it is exactly the failure that erodes trust in an automation, and it is why the whole system is now keyed on idempotency.
Architecture: before and after
flowchart LR
subgraph BEFORE["Before — agent as the integration layer"]
A1["Portal enquiry"] --> A2["Agent reads email"]
A2 --> A3["Retype into CRM"]
A3 --> A4["Retype into<br/>listing sheet"]
A4 --> A5["WhatsApp client<br/>by hand"]
A5 --> A6["Retype into<br/>e-sign"]
A6 --> A7["Email admin<br/>for accounting"]
A7 --> A8["Admin retypes<br/>into accounting"]
end
subgraph AFTER["After — event spine"]
B1["Portal / CRM / e-sign<br/>webhooks"] --> B2["Ingress<br/>verify, dedupe, persist raw"]
B2 --> B3[("Event store — Postgres<br/>append-only")]
B3 --> B4["Router<br/>event type to handlers"]
B4 --> B5["CRM handler"]
B4 --> B6["Notification handler<br/>WhatsApp templates"]
B4 --> B7["Accounting handler"]
B4 --> B8["Pipeline projection<br/>one live status"]
B5 --> B9{"Success?"}
B6 --> B9
B7 --> B9
B9 -->|No| B10["Retry — exponential<br/>backoff, capped"]
B10 --> B4
B9 -->|"Exhausted"| B11["Dead-letter queue<br/>alert to ops"]
endThe agent is no longer a node in the diagram. That is the whole change.
Idempotency and replay
sequenceDiagram
participant S as Source system
participant I as Ingress
participant E as Event store
participant H as Handler
participant T as Target system
S->>I: webhook (may repeat, may reorder)
I->>I: verify signature, compute event_key
I->>E: INSERT ... ON CONFLICT (event_key) DO NOTHING
alt Duplicate
E-->>I: 0 rows — already seen
I-->>S: 200 OK, no side effects
else New
E-->>I: stored
I->>H: dispatch
H->>T: write with idempotency_key = event_key + handler_id
alt Transient failure
T-->>H: 5xx / rate limit
H->>H: retry with backoff, then dead-letter
else Success
T-->>H: ok
H->>E: record handler outcome
end
endEvent key. Derived from source system, source event ID and a content hash — so a redelivered webhook and a replayed one collapse to the same key. Deduplication happens at insert, in the database, not in application logic where a race can slip through.
Replay is safe by construction. Every handler is keyed on event_key + handler_id. Replaying the entire event store re-runs nothing that already succeeded. That property is what makes it possible to fix a handler bug and reprocess a week of history without spamming clients.
Ordering. Handlers do not assume it. State transitions are validated against the current projection — a "price changed" event carrying older data than the projection holds is recorded and discarded, not applied.
Retries. Exponential backoff with jitter, capped attempts, then dead-letter with an alert. Rate-limit responses are honoured with their retry-after header rather than retried blindly.
Nothing silently disappears. Every event ends in one of three states: applied, deliberately discarded with a reason, or dead-lettered with an alert. There is no fourth state, and that is the property that lets a broker trust the pipeline dashboard.
The data model and WhatsApp handling
One transaction is the aggregate root. Property, client, agent, documents and milestones hang off it. Every source system's identifier is stored as an external reference against the transaction rather than the transaction being duplicated per system, so "which record is real" has one answer. The pipeline dashboard is a projection rebuilt from the event store, which means it can be regenerated from scratch and never drifts from the events that produced it.
Client notifications run on pre-approved WhatsApp Business Platform templates, one per milestone type, with variables bound from the transaction aggregate. Free-text replies from clients are handled inside the 24-hour service window and routed to the owning agent — the system never free-texts outbound. Opt-out is honoured at the transaction level and persisted, so a re-run or replay cannot resurrect a message to someone who opted out. Quiet hours are enforced in the notification handler, not left to whenever an event happens to fire.
What we did
1. Blueprint. Three weeks shadowing agents and admin staff, timing each manual step, and mapping the four highest-frequency transaction types end to end. This produced both the architecture and the baseline the 30 hours is measured against. Scope and price locked here.
2. Prototype. One event type — portal enquiry — through the full spine to CRM and notification. Built to prove idempotency and retry behaviour under deliberately duplicated and reordered webhooks, not to look good in a demo.
3. Forge. Full event catalogue, all five integrations, the transaction aggregate, the pipeline projection, and the WhatsApp template set through approval.
4. Harden. Chaos testing against the retail APIs — forced duplicates, forced reorder, forced rate limits, forced outage. Dead-letter alerting, replay tooling, and a runbook the client's own ops person can execute without us.
How we evaluated it
The test set. Not an ML eval — a correctness eval. A fixture set target of 200–400 recorded real webhook payloads across every event type — our standard coverage target for an event-spine build of this kind, not a number drawn from this engagement's own Blueprint documents, and not a count of live production events — replayed against the system in three modes: clean, duplicated, and shuffled out of order. The system must reach identical final state in all three. That is the acceptance criterion the build was signed off against.
The metric that mattered. Zero divergent final states across the three replay modes. Not throughput, not latency. A brokerage does not care whether a milestone propagates in 200ms or 3 seconds; it cares enormously whether the CRM and the accounting system disagree about a deal.
Latency, where it matters. Client-facing notifications are measured p50 and p95 from source event to WhatsApp delivery receipt. Internal propagation is measured but has a loose budget, deliberately — spending engineering effort on internal speed here would have been optimising the wrong axis.
Failure modes, and what happens when the system is wrong.
*Duplicate webhook.* Collapsed at insert. No duplicate message, no duplicate CRM entry. This is the bug that bit us and it is now structurally impossible.
*Out-of-order events.* Validated against the projection; stale updates recorded and discarded with a reason, never applied.
*Target system down or rate-limiting.* Backoff, retry, then dead-letter with an alert. The event is never lost, and it can be replayed once the target recovers.
*Bad data from a source system.* Handler rejects, event is dead-lettered with the validation error attached. Wrong data does not propagate to four systems.
*A handler bug that already wrote wrong data.* Fix the handler, replay the affected range. Idempotency keys mean correct writes are not repeated and the wrong ones are corrected.
*Notification sent in error.* This is the residual risk we cannot fully engineer away. Mitigated by quiet hours, template constraints and a per-transaction notification log an agent can inspect before they call a client back.
What we would do differently
We would have built replay tooling in the Prototype phase. We built it in Harden. Between those two points we fixed handler bugs by hand-writing correction scripts, which is slow and is exactly how you introduce a second bug.
We should have run the WhatsApp template approvals in week one. Template review is an external dependency with its own clock, and we discovered its lead time when it was on the critical path rather than before.
The 30-hour baseline should have been agreed in writing with the client before we timed anything. We timed it properly, but "which steps count" was settled after the measurement rather than before, and that is the wrong order for a number that ends up on a public page.
We over-built the pipeline projection's filtering. Agents use three views. We built eleven. That was us designing for a brokerage we imagined rather than the one in front of us.
What is still running
Live across all offices. Open workstreams: filling the event-volume and dead-letter figures on this page from the event store, adding leasing renewals as an event type, and moving the dead-letter runbook fully to the client's ops team so they stop needing us for replays.