Available for work — Book a 30-minute call

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.

Role
Built and maintained
Sector
Aesthetic medicine · allied health / NDIS
Status
Active in production
Stack
n8n, HubSpot CRM v3 API, two practice-management APIs, Slack, JavaScript

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 limit and page outright — cursor pagination only, with the cursor returned as a relative path that must be host-prefixed.
  • It requires a User-Agent header 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 null while 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

The n8n graph for the first practice-management system's appointment sync, carrying two independent paths on one canvas. Above: a fifteen-minute poll builds a window running one hour back and thirty days ahead, pulls appointments, transforms them into a gateway payload, and upserts to the CRM ten at a time, with any failure summarised to Slack. Below: a webhook takes a single appointment straight through one upsert and answers OK or Error. (select to enlarge)
Both halves of one sync on a single canvas - a webhook for immediate updates, and a fifteen-minute poll behind it as the safety net for anything the webhook misses. The 'Limit' node is labelled '(Deactivated)': it is switched off. Select the image to enlarge it — a graph this wide is not legible on a phone.
The n8n graph for the second practice-management system's appointment sync: a fifteen-minute poll or a manual trigger builds a chunk list, an outer loop runs per chunk polling appointments and exploding and filtering them, and an inner batch upserts one at a time to the CRM - recording the last run on the done branch and summarising any failure to Slack. (select to enlarge)
The second practice-management system reaching the same CRM. The first batches ten at a time; this one goes one at a time inside a chunked outer loop. The two vendor APIs are not alike, which is what the nine workflows exist to absorb. Select the image to enlarge it — a graph this wide is not legible on a phone.

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.

Book a 30-min call