RefClin · Developers

The RefClin API

Connect your laboratory, imaging centre or practice software to RefClin. Pull the cases sent to you, move them through your own workflow, quote them, message the practice and invoice — without anybody opening a browser.

Getting started

  1. 1. Ask the practice or laboratory whose data you are integrating with to create an API key: Admin → API keys → New API key. They choose which user the key acts as; make it a user created for your integration, not a person's account.
  2. 2. Note the account's address. Every request goes to that account's own subdomain — https://their-clinic.refclin.ai/api. A key issued by one account cannot be used against another.
  3. 3. Call the API with the key in the Authorization header.
curl https://their-clinic.refclin.ai/api/tenant/referrals?direction=received \
  -H "Authorization: Bearer rck_a1b2c3d4e5f6_…"

Authentication

A bearer token on every request. Keys start rck_ so they are recognisable as credentials if one is ever pasted somewhere it should not be.

A key acts as a user. It has that user's role and that user's scope, and nothing else. There is no separate permission model to learn: whatever that user can see and do in RefClin is exactly what your integration can see and do.

A key stops working the moment it is revoked, when it expires, when the user it acts as is suspended or deleted, or when the account is deactivated. Any of those is a valid way to switch an integration off.

The key is shown once, when it is created. RefClin stores only a fingerprint, so a lost key is replaced, never recovered.

Conventions

Money
Always an integer in the smallest unit — pence for GBP. £180.00 is 18000. Never a decimal. This is the most expensive thing to get wrong in this API: 180.00 bills the practice £1.80.
Dates
ISO 8601 in UTC, e.g. 2026-08-22T09:14:02.431Z. Sent and returned.
Ids
UUIDs. Never guess or construct one; use the ids you were given.
Bodies
JSON, with Content-Type: application/json. Unknown properties are rejected — see Errors.
Polling
No rate limit is enforced today. Poll no more than once a minute per account, and treat that as good manners rather than as a promise that a limit will never exist.

Errors

Standard HTTP status codes. The body carries a message meant for a developer, not for a patient.

{ "statusCode": 400, "message": ["property shade should not exist"], "error": "Bad Request" }

The one that will catch you: RefClin rejects a request containing any property it does not recognise — the whole request, not the property. Sending an extra field you thought would be ignored fails the call with a 400. Send only what is documented.

400The body is wrong — a missing field, a bad type, or an unknown property.
401The key is missing, malformed, revoked, expired, or its user is disabled.
403Authenticated, but the user this key acts as is not allowed to do that.
404Not found — or found and not yours. The two are deliberately indistinguishable, so that a wrong id cannot be used to discover what exists in another account.
409A conflict, such as an invoice number you have already used.

Cases

A case is one piece of work sent to you: a laboratory prescription, an imaging request, a referral to a specialist. Everything else in this reference hangs off a case id.

GET/tenant/referrals

List cases

The cases sent to your account. Newest first. This is the endpoint an integration polls.

Query
directionreceived | sent | allreceived (default) — work sent TO you. sent — work you sent to a partner. all — both.
statusstringOne status, or several comma-separated. Unknown values are ignored rather than rejected.
typeEXTERNAL | INTERNALExternal work versus your own account’s internal cases.
siteIduuidOnly cases for one of your locations.
Response
[
  {
    "id": "9f1c…",
    "referralNumber": "REF-260822-51A72396",
    "status": "RECEIVED",
    "patientFullName": "Jane Smith",
    "patientDob": "1990-05-12T00:00:00.000Z",
    "isUrgent": false,
    "originTenantId": "3ab8…",
    "requestDetailJson": { "kind": "LAB", "funding": "NHS", "workType": "Crown" },
    "labFeeItemsJson": [],
    "createdAt": "2026-08-22T09:14:02.431Z"
  }
]
  • Poll no more than once a minute per account. There is no rate limit enforced today; treat that as a courtesy, not a guarantee that there never will be.
  • A case sent to a laboratory lives in the LABORATORY’s account. The sending practice appears as originTenantId.
  • The treatment value (priceCents) is the sending practice’s private figure and is blanked on your copy. What the work is worth to them is not part of what they are buying from you.
GET/tenant/referrals/{id}

Get one case

The full case, including the work order, the patient, the referring dentist and the practice to return the finished work to.

  • For a multi-site practice, originSite carries the address the work must go back to. Using the head-office address instead is the commonest way appliances arrive at the wrong building.
  • The first time anyone at your account opens a case, both sides’ timelines record it. The sending practice can see that you have looked at it.
GET/tenant/referrals/{id}/timeline

Case history

Every recorded event on the case — status changes, work-order amendments, messages, documents.

Moving a case along

One endpoint drives the whole lifecycle. Which statuses are offered depends on what kind of account yours is: a laboratory gets a production lifecycle, a clinic gets a clinical one.

PATCH/tenant/referrals/{id}/status

Set the status

Accept a case, move it through production, dispatch it, close it.

Body
statusrequiredstringThe new status.
cancelReasonstringRequired when cancelling. It is shown to the sending practice.
Request
{ "status": "PLAN_ACCEPTED" }
  • Laboratory lifecycle: RECEIVED → PLAN_ACCEPTED (accepted) → TREATMENT_SCHEDULED (in production) → TREATMENT_COMPLETE (ready) → DISPATCHED → REPORT_SENT (delivered) → CASE_CLOSED.
  • CASE_CLOSED means finished. CLOSED and CANCELLED mean the work will not happen, demand a reason, and feed the practice’s lost-revenue reporting — do not use them for a completed job.
  • ON_HOLD parks a live case without cancelling it and without counting it as lost.
  • You can move a case backwards. Clicking the wrong stage should not be permanent.

What the work costs

The fee lines on a case are what you are charging the practice for it. Both sides may edit them, and for an NHS case they feed the prescribing dentist’s monthly laboratory budget.

PATCH/tenant/referrals/{id}/lab-fees

Set the fee lines

Replaces the whole set. Send every line you want to keep, not just the new one.

Body
itemsrequiredarrayUp to 50 lines of { label, priceCents }.
Request
{
  "items": [
    { "label": "Zirconia crown", "priceCents": 18000 },
    { "label": "Custom shade", "priceCents": 2500 }
  ]
}
  • priceCents is an integer in pence. £180.00 is 18000. There are no decimals anywhere in this API.
  • An empty array clears the fees, which for an NHS case releases the credits it had committed.
  • Once you have issued an invoice for the case, the invoice is what counts against the practice’s NHS budget and these lines no longer are.
  • Add "currency" to a line ("GBP", "EUR", "USD") if you do not price in sterling. Omitted means GBP.

Agreeing what the job costs

A case ordered from your published price list arrives with a price already on it. That is the practice’s order, not an agreement: you accept it, change it, or turn the job down. The state lives on the case as labQuoteStatus and is one of PROPOSED, ACCEPTED, REVISED or DECLINED. It is separate from the case status, which says where the work is rather than whether the price is agreed.

POST/tenant/referrals/{id}/quote/accept

Accept the job at the price on it

Agrees the price and moves a case still sitting in Received to Accepted. Only valid while the case is PROPOSED.

Request
{}
  • You cannot accept a price you set yourself. After you revise, it is the practice that confirms.
POST/tenant/referrals/{id}/quote/revise

Change the price

Replaces the fee lines and hands the case back to the practice to confirm. Valid from PROPOSED and from ACCEPTED, so a job already under way can be repriced when you find more work than the order described.

Body
itemsrequiredarrayThe full set of lines, same shape as the fee endpoint. At least one.
notestringWhy the price changed. The practice sees this.
Request
{
  "items": [
    { "label": "Zirconia crown", "priceCents": 18000 },
    { "label": "Remake, impression short", "priceCents": 2500 }
  ],
  "note": "The upper left impression was short, we remade it."
}
  • The case stays where it is in your workflow. Only the price goes back for agreement.
  • A revision does not clear the practice’s cost approval. Their own rule re-opens it if the cost rose and leaves it alone if it fell.
POST/tenant/referrals/{id}/quote/decline

Turn the job down

Returns the case to the practice with your reason. Only valid before you have accepted.

Body
noterequiredstringWhy you cannot take it. The practice sees this.
Request
{ "note": "We do not make this appliance." }
  • Once you have accepted, this is refused. Walking away from work you took on is a cancellation, which the practice needs to see as one.
POST/tenant/referrals/{id}/quote/confirm

Confirm a revised price

The practice side of the handshake, listed here so integrators on either side can find it. Only valid while the case is REVISED.

Request
{}

Sending the work back

Recording a dispatch does two things in one call: it stores the carrier and consignment number, and it moves the case to DISPATCHED. RefClin builds the tracking link and a person clicks it; nothing is ever sent to a carrier from RefClin, so no carrier becomes a data processor.

GET/tenant/referrals/carriers

The carriers you can record

Returns [{ code, name, trackable }]. Build your picker from this rather than hardcoding codes: a code this returns is by construction one the dispatch endpoint accepts. COURIER, COLLECTED and HAND come back with trackable false — they are ways work moves, not carriers, and a consignment number on one is rejected.

POST/tenant/referrals/{id}/dispatch

Record how it went back

The laboratory holding the case. Also marks it DISPATCHED, unless the case is already past that point.

Body
carrierCoderequiredstringA code from the carriers endpoint.
carrierNamestringRequired when carrierCode is OTHER: the name to show the practice.
trackingNumberstringLetters, digits, spaces and hyphens, 3 to 50 characters. Rejected on an untrackable carrier.
trackingUrlstringAn https:// link, for a carrier RefClin does not list. No credentials in the URL.
notestringLogistics only. The practice reads it, and it is not written to the audit log.
Request
{
  "carrierCode": "DPD",
  "trackingNumber": "JD0002123456",
  "note": "Two boxes"
}
  • A number that does not look like one is REJECTED rather than dropped, because a practice told there is tracking when there is not is worse than a 400 you can see.
  • Call it again to correct a mistyped number. The sent date is kept, and a case the practice has already confirmed arrived is not moved back to DISPATCHED.
  • GET /tenant/referrals/{id} returns a resolved "shipment" object: carrierName, trackingNumber, trackingUrl and untracked. Use trackingUrl rather than building one yourself; it is resolved on read, so a carrier changing its URL does not leave old cases pointing at a dead link.

How a practice pays you

Every customer is on one of two arrangements: ON_ACCOUNT (work first, settle within the agreed days) or PREPAY (the job does not start until your invoice for it is paid). PREPAY is enforced by the API, so an integration driving a case forward needs to know about it.

GET/tenant/providers/{providerTenantId}/prices

The price list, priced for you

Every active service the provider publishes, with the price YOU pay: any rate reserved for your practice is already applied. Each row carries the list price alongside, the VAT split, and the turnaround in days.

  • The customer is always the authenticated account. There is no parameter for it, by design.
GET/tenant/providers/{providerTenantId}/terms

Your payment terms with a provider

Returns { terms, days, isAgreed }. terms is PREPAY or ON_ACCOUNT; days is the settlement period on account and null on prepay; isAgreed is false when you are on the provider’s default rather than an arrangement of your own.

  • On a PREPAY case, PATCH /tenant/referrals/{id}/status returns 400 when you try to move it into production before an invoice is paid. The message names the missing step (raise an invoice, send it, or wait for payment) and is safe to show a user.
  • The gate covers every status from "in production" onward, so jumping a case straight to DISPATCHED does not get past it.
  • Cancelling and closing are never gated: a case you cannot pay for must still be stoppable.

Talking to the practice

Messages on a case, visible to both sides. This is where a technician queries a shade rather than telephoning.

GET/tenant/referrals/{id}/messages

Read the thread

Every message on the case, oldest first, from both sides. Includes who wrote each one and when.

POST/tenant/referrals/{id}/messages

Post a message

Notifies the practice by their chosen channels.

Body
bodyrequiredstringThe message text.
Request
{ "body": "Shade on the prescription reads A2 but the photo looks A3.5 — please confirm." }
  • Do not put anything here you would not want in a clinical record. These messages are part of the case history and cannot be deleted.

Files

Scans, photographs and documents attached to a case. Uploads are two steps: ask for a URL, then upload to it directly. Nothing large passes through the API itself.

GET/tenant/referrals/{id}/documents

List the files on a case

What the practice attached, and what you have attached back.

POST/tenant/referrals/{id}/documents/presign

Ask for an upload URL

Returns a short-lived URL to PUT the file to, and the key to confirm with afterwards.

Body
filenamerequiredstringOriginal file name.
contentTyperequiredstringMIME type.
POST/tenant/referrals/{id}/documents/confirm

Confirm the upload

Attaches the uploaded file to the case. Until this call, nothing is visible.

GET/tenant/referrals/{id}/documents/{documentId}/download

Download a file

Returns a short-lived URL. Do not cache it; ask again when you need it.

Invoices

Bill the practice for a case. Your own numbering is accepted and kept, so an invoice can carry the reference your accounts package already uses.

POST/tenant/invoices

Create a draft invoice

Nothing is sent until you send it.

Body
recipientTenantIdrequireduuidThe practice being billed.
referralIduuidThe case. Omit only for a general invoice not tied to one.
numberrequiredstringYour invoice number. Letters, digits, hyphen and slash, up to 40.
currencystringThree letters. Defaults to GBP.
lineItemsrequiredarrayAt least one line.
Request
{
  "recipientTenantId": "3ab8…",
  "referralId": "9f1c…",
  "number": "INV-2026-00184",
  "currency": "GBP",
  "lineItems": [
    {
      "description": "Zirconia crown",
      "quantity": 1,
      "unitPriceCents": 18000,
      "itemType": "LAB_WORK",
      "funding": "NHS",
      "discountPct": 0
    }
  ]
}
  • itemType: LAB_WORK, MATERIALS, REMAKE, DELIVERY, IMAGING, CLINICAL, OTHER.
  • funding: NHS, PRIVATE or NOT_DETERMINED — per line, because a month’s statement mixes both and the practice reconciles it against its own NHS claim.
  • An invoice links to ONE case. Billing several cases on one invoice charges the whole amount to the single case you named, which will make the practice’s NHS budget wrong.
POST/tenant/invoices/document/presign

Upload your own invoice

Returns a short-lived URL to PUT your invoice PDF to, and the key to read it with. Use this when your own accounting system produced the invoice and you do not want to enter it twice.

Body
filenamerequiredstringOriginal file name.
mimeTyperequiredstringapplication/pdf, image/jpeg, image/png or image/webp.
sizeBytesrequiredintegerUp to 10 MB.
POST/tenant/invoices/document/extract

Read the uploaded invoice

Reads the document and returns its fields, for you to check before creating the invoice. Pass the storage key back on POST /tenant/invoices to keep the document attached.

Body
storageKeyrequiredstringThe key returned by the presign call.
Response
{
  "number": "INV-2026-00184",
  "currency": "GBP",
  "supplierName": "Infinity Dental Laboratory",
  "patientName": "Sophie Godfrey",
  "referralNumber": "REF-260825-F450F36E",
  "totalCents": 20500,
  "lineItems": [
    { "description": "Zirconia crown", "quantity": 1, "unitPriceCents": 18000 }
  ],
  "totalMismatch": false
}
  • Every field can be null. A document nothing could be read from returns empty fields rather than an error, so you are left where you started rather than blocked.
  • totalMismatch is true when the lines do not add up to the total printed on the document. It is never silently corrected: something was misread, and only a person should decide which figure is right.
  • itemType and funding are NOT extracted. Neither is reliably printed on a lab invoice, and a guessed NHS marker on a line the practice later reconciles against a real NHS claim is worse than a blank one.
  • This sends the document to OpenAI, which is a processor on RefClin’s register for exactly this purpose. Nothing from the document is logged.
GET/tenant/invoices/{id}/document

Read the attached document

A short-lived link to the invoice document, readable by both the laboratory that uploaded it and the practice being billed.

POST/tenant/invoices/{id}/send

Issue the invoice

Draft becomes sent. This is the moment the practice is billed.

  • For an NHS case, issuing is what draws the prescribing dentist’s monthly credits down.
POST/tenant/invoices/{id}/cancel

Cancel an invoice

Releases any NHS credits it had committed.

GET/tenant/invoices/issued

Invoices you have issued

With the case, the patient and the prescribing dentist on each.

Webhooks

Be told when something happens, instead of asking every minute. The practice or laboratory registers your URL under Admin, Webhooks, and every delivery is signed so you can prove it came from us.

GET/tenant/webhooks/events

The events you can subscribe to

The current list. Short on purpose: every event is a promise to keep sending it.

Response
{
  "events": [
    "referral.created",
    "referral.status_changed",
    "referral.message_created",
    "referral.fees_changed",
    "invoice.status_changed"
  ]
}
GET/tenant/webhooks

Endpoints registered on this account

What is registered and whether it is still receiving. The signing secret is never returned: it is shown once, when the endpoint is created.

Verifying a webhook

Every delivery carries X-RefClin-Signature. Check it before you act on anything: without that check the endpoint is an unauthenticated way for anyone to tell your system what to do.

const [t, v1] = header.split(',').map((p) => p.split('=')[1]);

// Reject anything older than five minutes. The timestamp is INSIDE the signed
// material, so a captured delivery cannot be replayed for ever.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return reject();

const expected = crypto
  .createHmac('sha256', YOUR_SIGNING_SECRET)
  .update(`${t}.${rawBody}`)
  .digest('hex');

// Constant time, not ===. A byte-by-byte comparison leaks the answer.
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return reject();

Sign the raw body, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.

Answer 2xx quickly, then do the work. We time out after ten seconds and treat that as a failure worth retrying.

Expect a delivery twice. Retries mean at-least-once, not exactly-once. X-RefClin-Event-Id is stable across attempts, so store it and discard one you have already handled.

Payloads carry ids, never patient details. Fetch what you need with your API key. That keeps one source of truth for what your integration may see, and keeps patient data off a URL typed into a form.

We retry on a network failure, a timeout, a 408, a 429 or any 5xx, backing off up to six hours and giving up after eight attempts, at which point the endpoint is switched off and the practice is shown why. Any other 4xx means you understood and said no, so we do not retry it.

Not yet available

Listed as plainly as everything else. The expensive mistake is not calling an endpoint wrongly — it is designing around one that does not exist.

Estimates the practice accepts or rejects

You can set fee lines, but there is no formal quote for the practice to approve, reject, or for you to revise. Whether a laboratory should start work before or after a price is agreed is a decision about clinical workflow, not about an API, and it is open.

Product codes

Fee lines and invoice lines carry a description, not a code. Nothing maps your internal catalogue onto a line.

One invoice covering several cases

An invoice names a single case. This is the limit most likely to surprise a laboratory that bills monthly.

Contact

Building something and stuck, or need one of the gaps above closed? Write to hello@refclin.ai. Tell us what your system needs to do rather than which endpoint you would like — the first is a problem we can solve, the second is a guess at the solution.

Where this page and the API disagree, this page is what we intend to support. Raise the difference.