Available for work — Book a 30-minute call

Automated NPS & Review-Routing System

A reputation-management system for a healthcare clinic that surveys patients at a defined visit milestone, parses free-text replies into a satisfaction score, and routes by sentiment — inviting promoters to leave a public review while directing anything less positive to a private follow-up with a human. Built across a polling and dispatch engine and a CRM messaging layer, with cursor-based incremental sync, retry-safe queuing, and authenticated webhooks. A live mis-routing incident was traced to two compounding faults across both systems and corrected in each.

Role
Built and maintained
Sector
Massage therapy
Status
Active for the NPS → public-review path; a second review layer built but unpublished
Stack
n8n (self-hosted), Cliniko API, GoHighLevel, JavaScript

I built and hardened the polling, dispatch and parsing engine, and set up the CRM-side review messages from the agency's template. When they misrouted a reply, I diagnosed the cause and specified the fixes.

The problem

Public reviews are the primary trust signal for a local clinic, but asking indiscriminately risks surfacing unhappy patients publicly. The clinic needed to ask at the right moment and route only promoters to the public link.

What was built

Two independent scheduled pipelines in the automation layer — a 15-minute intake that polls attendees, counts sessions, validates, and queues; and a 1-minute dispatch that sends anything due — feeding a CRM messaging layer that sends the survey, receives the reply, posts it to a parser, and branches on the resulting score.

The hard part

A patient replied "11" — meaning eleven out of ten, an enthusiastic promoter — and received the detractor apology. The worst possible outcome.

Two compounding faults, in two different systems:

  1. The parser. Its hyperbole guard handled three-or-more-digit numbers and explicit "N out of 10" forms, but a bare two-digit number matched none of the number patterns, so the score stayed null and nothing was written.
  2. The CRM. With the score field unset, the detractor condition was <= 5 — and an empty field still satisfies <= 5. So any reply the parser couldn't understand routed to the apology.

The parser bug made it silent. The CRM condition made it harmful. Neither was obviously wrong on its own.

The fixes had to land in both places: in the parser, match the first integer and clamp anything above ten down to ten; in the CRM, change the detractor condition to >= 1 AND <= 5 so an empty field falls through to a re-ask branch instead.

Reviewing around the incident surfaced more — a score field that was never cleared between cycles, so a returning patient could branch on a months-old score; a fixed ten-second wait between writing the score and reading it back, which is a race; and a completely unauthenticated parser webhook that would have let anyone POST an arbitrary score against any contact ID. That was closed with a shared-secret header that fails closed.

What can be verified

  • The system runs in production and routes replies by sentiment
  • a live mis-routing incident was diagnosed to root cause across two systems and fixed in the parser
  • a security hole was found and closed.

The workflows

The CRM-side survey send: an inbound webhook from the scheduling tool sets the contact's first name, email and phone, sends an SMS asking for a score, and tags the contact so the same person is not surveyed twice. (select to enlarge)
Step one of three - sending the survey. The tag written at the end is what stops the same patient being asked again. Select the image to enlarge it — a graph this wide is not legible on a phone.
The n8n graph for the score parser: a webhook from the CRM receives the patient's free-text reply, a code node parses a score out of it, and a check branches - a score found is written back to the CRM as nps_score, no score found falls straight through - with both paths converging on a single response to the CRM. (select to enlarge)
Parsing a free-text reply into a score. Both branches converge on one response node, so the CRM is answered whether or not a score could be found - a reply that parses to nothing is still a reply that was received. Select the image to enlarge it — a graph this wide is not legible on a phone.
The CRM-side reply handler: a customer reply fires a webhook, waits, then hits a first-match-wins condition with four branches - promoter for a score of eight or more, passive for six or more, detractor for five or less, and a fourth branch for when none of the conditions are met, which asks the person to reply with a number. (select to enlarge)
Step three - the routing. The fourth branch is the one worth looking at: a reply that parses to no score at all is not silently dropped or guessed at, it is asked again. Select the image to enlarge it — a graph this wide is not legible on a phone.
An n8n graph in two rows: an intake pass that reads attendees from a practice-management API, validates each booking and patient, pairs them to a CRM contact by phone and writes a session count; and a dispatch pass that loads a due queue, drops anything too old, and sends the survey trigger to the CRM, logging sent, failed and skipped separately. (select to enlarge)
The intake and dispatch halves of the survey engine. Every branch ends in its own log sink - sent, failed and skipped are recorded separately rather than collapsed into one status. 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