Back to BlogIndustry Guides

A Referral Management System for Clinics

Rupak Amin

Founder & Lead Engineer, RAITHub

9 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

A referral falls through when no one owns its state. Build a referral management system as a state machine (received, triaged, booked, seen, closed), give each clinic role-based access to only its own referrals, audit every change, and run a check that flags any referral sitting too long in one state. Then no referral goes quiet. This is engineering guidance; your compliance partner sets the data rules.

This post is about the software, not clinical practice or compliance. What patient data you may store, who may see it, and how it must be protected are governed by health-data law that differs by jurisdiction; this is general information, so confirm the rules with your compliance partner and adviser. Triage priority is a clinical decision made by clinicians, not by the software. If you would rather have it built for you, see how RAITHub would build this below.

Why do referrals fall through the cracks?

Because a referral is a hand-off between organisations, and a hand-off with no owner is where work disappears. A letter or a fax arrives, someone means to action it, and nothing in the system forces the next step or notices when it is overdue. The fix is to give every referral an explicit state and a clock: the system always knows which stage each referral is at, who is responsible, and which ones have been waiting too long.

ProblemWhy it happensThe fix
Referral lostNo single record of its current stageOne state per referral, with allowed transitions
Nothing chases itProgress depends on a person rememberingA scheduled check that flags referrals overdue in a state
Wrong clinic sees itAccess hidden in the UI, not enforcedRole-based access enforced in the database per clinic
No record of changesEdits overwrite with no historyAn append-only audit trail on every change

What does the data model look like?

A referral has a current state, a sending and a receiving clinic, and a history. Keep clinical content to what the receiving clinic needs, and scope every row so a clinic can only read its own.

CREATE TABLE referrals (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  from_clinic_id bigint NOT NULL REFERENCES clinics(id),
  to_clinic_id   bigint NOT NULL REFERENCES clinics(id),
  patient_ref    text NOT NULL,                 -- a reference, with PHI access controlled
  specialty      text NOT NULL,
  priority       text NOT NULL,                 -- set by a clinician, not the system
  state          text NOT NULL DEFAULT 'received',
                 -- received | triaged | booked | seen | closed | returned
  state_since    timestamptz NOT NULL DEFAULT now(),  -- for the overdue check
  created_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE referral_events (
  id          bigserial PRIMARY KEY,
  referral_id uuid NOT NULL REFERENCES referrals(id),
  from_state  text,
  to_state    text NOT NULL,
  by_user     bigint NOT NULL,
  note        text,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX referrals_by_to_clinic ON referrals (to_clinic_id, state);

Keep state_since current on every transition, so a scheduled job can find referrals that have waited too long in a given state. Store a patient reference and control access to the actual record separately; what patient data you hold and how is for your compliance partner to define.

How do you stop a referral going quiet?

With a scheduled check, not a person's memory. Each state has a target time (triage within X, booked within Y). A job runs regularly, finds referrals past their target in their current state, and surfaces them, to a worklist, a manager, or a reminder, so a stalled referral is seen before the patient chases it. Make the check idempotent so running it twice does not raise the same flag twice. This is the same overdue-escalation pattern used for maintenance SLAs, covered in a maintenance ticketing SaaS with SLA escalation; the shape is identical even though the domain differs.

-- Referrals that have sat too long in their current state (the overdue worklist).
SELECT id, to_clinic_id, specialty, state, state_since
FROM referrals
WHERE state NOT IN ('seen', 'closed')
  AND state_since < now() - make_interval(hours => $1);  -- target time for this state

Who should see which referrals?

Only the clinics party to a referral, enforced in the database, not the interface. The sending clinic sees the referrals it made; the receiving clinic sees those sent to it. Build this with permission-based roles and scope every query to the clinic, so a missed filter in one place cannot leak another clinic's patients. Back it with database-level row scoping, the same discipline as multi-tenant isolation, and test that a clinic requesting a referral outside its own gets a 404. The role pattern is in designing SaaS authorization.

Why audit every change?

Because in a clinical hand-off you must be able to show who did what and when, and because a referral's history is part of patient care. Record every state change and note as an append-only event: the from-state, the to-state, the user and the time. Never overwrite; append. This gives you a defensible record and lets you reconstruct a referral's journey exactly, which matters when care is questioned or data access is reviewed. The audit pattern is in designing an append-only audit log.

How do you test a referral system?

Test the transitions, the isolation, and that overdue referrals surface.

import { describe, it, expect } from 'vitest'
import { transition, overdue, forClinic } from './referrals'

describe('referral workflow', () => {
  it('flags a referral overdue in its current state', async () => {
    const r = await seedReferral({ state: 'received' })
    await ageStateSince(r.id, { hours: 72 })
    const list = await overdue({ state: 'received', targetHours: 48 })
    expect(list.map((x) => x.id)).toContain(r.id)
  })

  it('one clinic cannot read another clinic referrals', async () => {
    const r = await seedReferral({ toClinic: 1 })
    await expect(forClinic(2).get(r.id)).rejects.toMatchObject({ status: 404 })
  })
})

Add a test that each transition writes exactly one audit event, and one that an illegal transition (closing a referral that was never seen, say) is rejected.

Buy, build or hire?

OptionChoose this whenThe catch
A module in your EHR or clinic platformYour clinics use one platform that includes referralsYou fit its workflow and data model; cross-organisation referrals and your own states may not map
Email, fax and a shared inboxVery low volume between two clinicsNo ownership, no overdue tracking, no audit trail; referrals fall through
Custom buildReferrals cross organisations, you need your own workflow, isolation and audit, or integration with several systemsYou own the workflow and the record; the data rules still come from your compliance partner

How long does it take to build yourself, and what is the risk?

For an experienced developer building the referral workflow, clinic isolation, the overdue check and an audit trail, our estimate is 4 to 6 weeks at a fixed scope. The main risk is the data layer: health data carries strict obligations on storage, access and transfer that differ by jurisdiction, and no app is compliant on its own, compliance is an obligation on your organisation. This is general information, so build the system with your compliance partner defining what it may hold and how it must be protected.

Why RAITHub for this

  • Workflows, roles and isolation in production. RAITHub enforces per-tenant isolation on Sundor Skin with row-level security (146 tables, 530+ tests) and runs multi-role workflows on PropDesk (1,024 tests). See the Sundor Skin case study.
  • Healthcare adjacency, stated honestly. RAITHub has built a healthcare scheduling app for a client; it has not shipped a regulated health product, and there is no published health case study, so weigh the evidence accordingly.
  • Audit by default. Append-only audit logs are standard, which is what a clinical hand-off needs.

When you don't need us

  • Your EHR's referral module fits. If its workflow and data model match your clinics, use it.
  • Volume is tiny. A documented manual process may be enough between two clinics.
  • You need a regulated, validated system. RAITHub builds no GxP-validated systems; use a vendor cleared for that.

How RAITHub would build this

  • Referral workflow: a state machine with allowed transitions and no illegal jumps, each transition audit-logged.
  • Overdue check: an idempotent scheduled job that surfaces referrals past their target time in a state.
  • Isolation and roles: clinic-scoped access enforced in the database, with permission-based roles and a 404 on cross-clinic access.
  • Data layer: patient data minimised, access-controlled and retained to rules your compliance partner sets.

Timeline: a first version fits the 4 to 6 week fixed-scope range; EHR or FHIR integration and multi-clinic reporting push it toward the 6 to 12 week backend range. See SaaS development and the HealthTech industry page. A related build is a prescription refill request workflow.

You receive: the workflow, isolation and audit code, tests gated in CI, handover docs, and full IP in your name under NDA. Data and compliance rules are defined by your partner, not claimed by RAITHub.

Frequently asked questions

Why do clinic referrals get lost?

Because a referral is a hand-off between organisations, and a hand-off with no owner is where work disappears. Give every referral an explicit state and a clock, so the system always knows which stage it is at, who is responsible, and which referrals have waited too long.

How do I stop a referral going quiet?

With a scheduled check, not a person's memory. Give each state a target time, run an idempotent job that finds referrals past their target in their current state, and surface them to a worklist or a manager so a stalled referral is seen before the patient chases it.

How do I keep one clinic from seeing another clinic's referrals?

Enforce access in the database, not the interface. Scope every query to the clinic, back it with row-level scoping, and give each referral a sending and receiving clinic. A test requests a referral outside a clinic's own and asserts a 404.

Why does a referral system need an audit trail?

Because in a clinical hand-off you must be able to show who did what and when, and the history is part of care. Record every state change as an append-only event with the from-state, to-state, user and time, and never overwrite, so a referral's journey can be reconstructed exactly.

Does RAITHub make a referral system HIPAA or GDPR compliant?

No app is compliant on its own; compliance is an obligation on your organisation. RAITHub builds access control, audit logging and data minimisation that support your programme, to rules your compliance partner defines. This is general information; confirm the obligations that apply with your adviser.

Does the software decide referral priority?

No. Triage priority is a clinical decision made by clinicians. The software records the priority a clinician sets and routes and tracks the referral accordingly; it does not make clinical judgements.

referral managementhealthtechstate machinerbacaudit trailclinic software

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.