Clinical Booking ↔ CRM Synchronisation
A production integration platform that keeps two clinical practice-management systems in sync with a CRM, so sales teams can see in real time whether a lead has booked, attended, or cancelled. Handles two entirely different vendor APIs — including one with no standard pagination, mandatory custom headers, and inconsistent name fields — with idempotent upsert, dual-key deduplication, retry logic, per-record error tolerance, and one-time historical backfill. Documented end to end so a future maintainer inherits the API's real behaviour, not its documentation.
I built the contact sync and the aesthetics clinic's appointment sync from scratch. The allied-health provider's appointment sync I repointed to the new CRM account and maintained.
The problem
Bookings, arrivals, cancellations and no-shows lived only in the practice-management system. The CRM held the sales pipeline but had no visibility of whether a lead had actually converted into an appointment — so a salesperson chasing a lead had no idea that person booked last Tuesday. Contact records went stale, because patients update their details with the clinic, not with the CRM. And two clients on two different practice systems meant the same business need required two different technical solutions.
What was built
Nine workflows split by client and by concern rather than one large synchroniser: scheduled contact sync, appointment state sync, two webhook gateways, a contact profile enricher, a bulk guest migration with delta poller, a one-shot historical backfill, and a reconciliation workflow for association drift.
The gateway pattern is the architectural idea: rather than pointing a source system at whichever workflow currently handles appointments, it gets one stable endpoint that upserts. Internal restructuring then never requires the source system to be reconfigured.
The hard part
Neither API behaves the way its documentation says. Every design decision came from testing against the live API and writing down what actually happened:
- One vendor rejects
limitandpageoutright — cursor pagination only, with the cursor returned as a relative path that must be host-prefixed. - It requires a
User-Agentheader or it rejects the request. - On the terminal page it returns
links: {}rather than a null cursor. The first implementation built the next URL from that missing value, produced an empty string, and the platform rejected an empty string as an invalid URL rather than treating it as "stop" — so the workflow fetched everything correctly and then crashed on the final page. The fix moved the stop decision into a completion expression, so the URL expression is only evaluated while a real next page exists. - It usually leaves first and last name
nullwhile putting the full name in a single field. - About four in five contacts have no email at all — they're provider records, not patients — and are skipped by design rather than creating junk CRM records that can never be matched.
- The other vendor's appointment end date is exclusive, so the last day silently vanishes unless you add one.
What can be verified
- Appointment state flows into the CRM automatically for both clinics
- the contact sync runs every 15 minutes with idempotent email-keyed upsert
- re-running produces no duplicates, verified explicitly rather than assumed
- the measured data-quality funnel on a live page: 100 raw → 81 skipped for no email → 19 eligible → 8 after deduplication.
The workflows
(select to enlarge)
(select to enlarge)
On numbers: every figure above is an artefact count or a measured technical value. No business-outcome metric, whether time saved, revenue or conversion, was captured on these engagements, so none is claimed.
On status: reflects repository evidence and platform backups, not a live systems check.