Organizations use this API to onboard clinicians and patients, order lab tests, follow each order to its result, and receive results. The patient takes the order to a patient service center for the visit, and the result comes back under the same requisition. Availability varies by state, patient and test: an order the lab cannot take is refused at once, with the reason. An order is checked when it arrives and answered at once; placing it with the lab follows within minutes (follow it with orders.status). Nothing you send waits on a person.
Labs
The API is not tied to one laboratory. Orders and tests name their lab (lab); today one lab is available, "national", and it is the default, so a request that names no lab goes to it. GET /v1/catalog lists the labs as labs: { available, default }; more are added there as QDX adds them, and naming one that is not listed is refused.
Basics
Base URL
https://api.qdx.systems. Every path below starts with /v1. A change that could break an organization gets a new path (/v2); fields and operations may be added to /v1 at any time, so ignore fields you do not know.
Authentication
Send Authorization: Bearer <token> on every request. Each organization has a test token (qrt_…) and a live token (qrp_…), created in the portal under Settings, API keys, and each shown once. A live token is available once your organization is active. Start with a test token (see Test mode). A request without a valid token is refused with 403 refused_by_policy.
Status page
https://qdx.systems/status shows whether the API and the lab are up. It needs no token. GET /v1/status returns the same as JSON. lab is up, down or unknown, and labs gives it for each lab by its id:
{
"api": "up",
"lab": "up",
"labs": {
"national": "up"
},
"checkedAt": "2026-10-03T14:00:00.000Z"
}
Rate limits
Per organization, in a sliding minute: 300 reads and 30 writes (GET and POST /v1/operations/{name} are reads; everything else is a write). Over the limit, the answer is 429 rate_limited with Retry-After.
Idempotency
Send Idempotency-Key: <your unique key> with any POST or PATCH. The first outcome is stored; the same key again returns that outcome with "replayed": true and does nothing at the lab. Keys are scoped to your organization and the route. Use one key per intended action and reuse it when you retry.
Try again later
When the lab cannot be reached, a write is refused before anything is sent: upstream_unavailable, with Retry-After in seconds. Every retryable error carries Retry-After.
Dates
Dates of birth are MM/DD/YYYY. orderedAt in orders.list and orders.for-patient is the lab's own YYYY-MM-DD HH:MM:SS. Every other date and time is ISO 8601.
Test mode
A test token (qrt_…) is answered by a simulator and never reaches the lab:
- The same requests and the same answers, each with
"mode": "test". Requisition numbers startT. - Documents are real PDFs stamped TEST MODE.
- A test order is
heldat once and can be edited or cancelled while held. POST /v1/withtest/ orders/ {reference}/ advance { "to": "received" }or{ "to": "resulted" }moves it on as though the patient had visited. A resulted test order has a sample report, and webhooks fire for it with"mode": "test"in the event data.- Test patients, orders and results are never visible to a live token, and live ones never to a test token.
- Test mode searches only tests already known to QDX, and a test order naming anything else is refused.
- A sample result lists each analyte of the tests ordered, with its LOINC code, a value, a unit and a reference range. The values describe no one. They are fixed for a result, so it reads the same every time you fetch it. Where the analyte has a known range, about one value in seven is out of range and flagged
HorL; the rest haveflag: null. Many sample results have no flag at all. - The change feed is live only:
GET /v1/changeswith a test token answers403 refused_by_policy, "Not available in test mode". Follow test orders withorders.status(ororders.statuses) and webhooks. - A diagnosis code is checked as an ICD-10 code, with or without the dot (
E29.1orE291), and may appear once in an order. Live also checks that the lab knows the code; test mode cannot ask the lab, so a well-formed code it has never seen is accepted. - Every operation answers in the same shape as live, with the same field names and the same values where live has fixed ones, filled with test records. Each of these is checked against the live answer's schema:
patients.list,patients.search,patients.get,patients.references,clinicians.references,orders.list,orders.for-patient,orders.status,orders.statuses,orders.requisition,orders.hold-letter,results.list,results.get,results.for-patient,results.unattachedandresults.report. The answer arounddatahasmode: "test"and noversionorfreshness. A test result isFinal, from labnational, attached to its patient's chart, with values (results.get) whosestatusisF. A test result is always attached, soresults.unattachedis always[].labCodeis the lab's code where QDX knows it and""otherwise.
Errors
Every error body is { category, retryable, operation, detail, consumerAction }. Decide from category and retryable; detail is for people.
| category | HTTP | retry | meaning | what to do |
|---|---|---|---|---|
not_found | 404 | no | no such record for your organization | stop; the identifier is wrong |
stale | 503 | yes | QDX's copy of the lab's data is older than the operation allows | retry after Retry-After; never treat the data as current |
refused_by_policy | 403 | no | the request is not allowed or not valid (detail says which) | fix it before sending again; the same request is refused again |
refused_unmatchable | 422 | no | the patient's details are not enough to match the record the request needs | stop; correct the patient's details rather than retrying |
upstream_unavailable | 502 | yes | the lab could not be reached or did not answer | retry with backoff; the request was not necessarily rejected |
contract_drift | 500 | no | the lab answered in a shape QDX does not recognize | stop and report it to QDX; QDX needs an update before this can succeed |
plan_expired | 409 | yes | the record changed at the lab while this request was being checked; nothing was sent | send the same request again; it is checked afresh against the record as it now is |
rate_limited | 429 | yes | this organization has sent more requests than its limit allows in the current window | wait the Retry-After seconds and send the request again |
A request that is not valid (a missing or malformed field, or a body that is not JSON) is refused with 403 refused_by_policy; detail names the field. A missing or invalid token is also 403, not 401. A path that does not exist is 404 not_found.
Identifiers
- Patient reference: yours, 1–14 letters, digits or hyphens, case-insensitive. QDX gives the lab a patient ID made of your organization prefix and this reference, so it never collides with another organization's. You always see your own reference.
- Order reference: yours, up to 50 characters, none of
| ^ & \ ~ < >. It is printed on the requisition as the lab reference and is how results come back to you. One reference, one order. - patientId: the lab's patient ID, returned when the patient is created with
POST /v1/patients(for a patient created inside an order, once that order is placed with the lab). - requisition: the lab's order number, returned once the order is placed with the lab (see Place an order). It does not change when an order is edited.
- Clinician: the ten-digit NPI.
Tests
GET /v1/testswith noq: every orderable test, a page at a time (see The whole catalog).GET /v1/tests?q=lipid: tests matching a name or code.?lab=chooses the lab's catalog (the default lab when absent). A test is orderable when the lab takes it and its results carry LOINC codes, so every value can be filed in an EMR; an order naming any other test is refused with the reason.GET /v1/tests/{code}(?lab=as above): one test, the questions it asks when it is ordered, and what to know before ordering it (see One test). A code the catalog does not hold is404 not_found.GET /v1/diagnoses?q=E29.1: ICD-10 diagnoses matching a code or words, as{ diagnoses, source }, each{ code, title }. Codes come back without the dot:E291for E29.1,Z0000for Z00.00. Documents (requisitions) and FHIR (reasonCode) show them with the dot, as a reader expects.diagnosisCodeson an order takes either form ("E291"or"E29.1"); an HL7 order'sDG1-3has the dot removed.
GET /v1/tests?q=lipid answers:
{
"tests": [
{
"code": "7600",
"title": "Lipid Panel",
"orderable": true
}
],
"source": "lab",
"lab": "national",
"mode": "live"
}
source says where the list came from: lab is the lab's current test list, kept is QDX's stored copy, and none means q was shorter than 2 characters (tests is then empty). It is for information; the tests are orderable or not whatever it says.
The whole catalog
GET /v1/, with no q, lists every orderable test, in alphabetical order of title as a person reads it (ignoring case and punctuation, so "Gliadin … (IgG, IgA)" comes before "Gliadin … (IgG)"; the order is the same on every request, so paging never skips or repeats a test), for an EMR that imports the catalog. cursor is where the page starts (0 or empty for the first page; a negative or non-numeric cursor is taken as 0); limit is 1 to 500 (200 when empty, 0 or not a number; a larger number is taken as 500). Answers 200:
{
"tests": [
{
"code": "496",
"title": "Hemoglobin A1c",
"category": "Chemistry",
"fasting": false,
"aliases": [],
"analytes": [],
"cpt": [],
"orderable": true,
"priceCents": 371
},
{
"code": "7600",
"title": "Lipid Panel",
"category": "Chemistry",
"fasting": true,
"aliases": [],
"analytes": [
{ "name": "TRIGLYCERIDES", "loinc": "2571-8" },
{ "name": "CHOLESTEROL, TOTAL", "loinc": "2093-3" },
{ "name": "HDL CHOLESTEROL", "loinc": "2085-9" },
{ "name": "LDL-CHOLESTEROL", "loinc": "13457-7" },
{ "name": "CHOL/HDLC RATIO", "loinc": "9830-1" },
{ "name": "NON HDL CHOLESTEROL", "loinc": "43396-1" }
],
"cpt": [],
"orderable": true,
"priceCents": 494
}
],
"total": 3,
"cursor": 0,
"nextCursor": 2,
"collectionCents": 1000,
"lab": "national",
"mode": "test"
}
Send nextCursor as the next cursor, and page until nextCursor is null. total is the number of tests in the whole catalog. Each test has:
category: a grouping such asChemistryorEndocrine/Hormones.fasting: true when the lab asks the patient to fast.aliases: other names for the test.cpt: its CPT codes. Both can be empty.analytes: what the test reports, each{ name, loinc }, as inGET /v1/tests/{code}. Empty when QDX has not read the test's details yet.priceCents: your price for the test ordered on its own, without the specimen collection fee;nullwhen it has no price yet.collectionCents(on the page): the specimen collection fee, charged once per order.
One test
GET /v1/tests/7600 answers:
{
"code": "7600",
"title": "Lipid Panel, Standard",
"orderable": true,
"questions": [],
"questionsKnown": true,
"lab": "national",
"details": {
"aliases": [],
"cpt": [],
"includes": [],
"analytes": [
{ "name": "TRIGLYCERIDES", "loinc": "2571-8" },
{ "name": "CHOLESTEROL, TOTAL", "loinc": "2093-3" },
{ "name": "HDL CHOLESTEROL", "loinc": "2085-9" },
{ "name": "LDL-CHOLESTEROL", "loinc": "13457-7" },
{ "name": "CHOL/HDLC RATIO", "loinc": "9830-1" },
{ "name": "NON HDL CHOLESTEROL", "loinc": "43396-1" }
],
"about": null,
"preparation": null,
"fasting": null,
"specimen": {
"preferred": null,
"minimumVolume": null,
"alternatives": [],
"container": null,
"temperature": null,
"stability": [],
"rejection": []
},
"collectionNotes": null,
"method": null,
"turnaround": null,
"ageLimits": null
},
"mode": "test"
}
questionswhat must be answered when the test is ordered. Each question hascode,question,required,input(select,radio,dateortext),options(each{ value, label }) anddefault.questionsKnowntrue whenquestionsis the test's full list. False when QDX has not read the test's questions yet:questionsis then empty, and an order is still checked against the real list.detailswhat QDX knows about the test, ornullwhen it knows nothing yet. A field isnull(or an empty list) when the lab says nothing about it, anddetailscan hold onlyageLimits.aliasesother names for the test.cptits CPT codes.includesthe tests or components it includes, by name.analyteswhat it reports, each{ name, loinc };loincisnullwhere the lab gives none.aboutwhat the test is for, in the lab's words.preparationhow the patient prepares, in the lab's words.fastingrequired,preferred, ornullwhen the preparation says nothing about fasting.specimenpreferredspecimen,minimumVolume, other acceptable specimens (alternatives), transportcontainerandtemperature,stabilityandrejectioncriteria.collectionNotesinstructions for the person collecting the specimen.methodhow the lab measures it.turnaround: when the lab runs it.ageLimits{ min, max }patient ages the test is offered for, ornullwhen there are none.
Panels
A panel is a named set of tests ordered together. Ordering from a panel only fills in its tests; the order is still its testCodes.
- QDX panels (
scope: "qdx", ids likeqdx-men-level-4) are the same for every organization and cannot be changed. An administrator can hide one for the practice in the portal; hidden panels are not returned. - Practice panels (
"practice") are made with a practice key (or by an administrator in the portal) and seen by everyone in the practice. - Your panels (
"mine") are made with a clinician-scoped key (or by that clinician in the portal) and are seen by no one else, administrators included.
GET /v1/panels: every panel you may use, QDX's first:{ panels: [{ id, scope, name, label, group, description, testCodes, hidden, editable, tests: [{ code, title, orderable }], estimate: { priceCents, totalCents, collectionCents, complete } }] }.estimateis the panel's orderable tests ordered on their own, asPOST /v1/orders/estimatewould answer:priceCentsis the tests at their own prices,collectionCentsthe specimen collection fee, andtotalCentsthe two together.priceCentsandtotalCentsarenullwhen a test has no price yet.completeis false when a test has no price or is no longer orderable. A test that stops being orderable stays in the panel withorderable: false.GET /v1/panels/{id}: one panel.POST /v1/panelswith{ "name": "Thyroid follow-up", "testCodes": ["899", "866"], "description": "" }:201with{ panel }. Names are 1 to 80 characters and unique within their scope (any case:409otherwise); a description is at most 280 characters; 1 to 50 tests, each orderable.PATCH /v1/panels/{id}with any ofname,description,testCodes;DELETE /v1/panels/{id}. Orders placed from a panel keep its name as it was.
Writes
- Onboard a clinicianPOST
/v1/clinicians - Create a patientPOST
/v1/patients - Place an orderPOST
/v1/orders - Estimate an orderPOST
/v1/orders/ estimate - Edit an orderPATCH
/v1/orders/ {reference} - Cancel an orderPOST
/v1/orders/ {reference}/ cancel - Order by HL7POST
/v1/hl7
verified on the patient, order, edit and cancel answers says whether reading the record back from the lab afterwards showed what was sent. It is reported rather than hidden; false is rare and means the write happened but did not read back exactly as sent.
Onboard a clinician
POST /v1/
{
"name": "Example, Casey",
"npi": "1234567893",
"state": "FL",
"zip": "33401"
}
All four are required: the name, the ten-digit NPI, and the clinician's US state (two letters) and zip. The NPI's check digit and the national registry's name for it are checked; a name that is not the registry's for that NPI is refused. The clinician is then yours to order under; you can only order under clinicians you onboarded. Answers 200:
{
"npi": "1234567893",
"registryName": "EXAMPLE, CASEY",
"state": "verified",
"detail": "EXAMPLE, CASEY is verified against the national…",
"statusAvailableFrom": "clinicians.references"
}
detail says the same for people, in full: "EXAMPLE, CASEY is verified against the national registry and bound to this organization. The lab registers a clinician when an order names them; after the first order QDX checks that the lab did, and registers them itself if it did not."
In the request state is the clinician's US state; in the answer it is the registration state: registered if the lab already lists them, or verified if the lab will register them with the first order that names them (QDX checks afterwards, and registers them if the lab has not). clinicians.references reports whether each clinician can be named on an order.
Create a patient
POST /v1/
{
"reference": "P-0001",
"lastName": "Doe",
"firstName": "Jane",
"dateOfBirth": "01/15/1985",
"sex": "F",
"phone": "5555550100",
"phoneType": "Mobile",
"address1": "1 Main St",
"city": "Dallas",
"state": "TX",
"zip": "75201",
"email": "jane.doe@example.com"
}
Required: reference, lastName, firstName, dateOfBirth (MM/DD/YYYY), sex (M, F or U), phone. Address and email are optional; leave out what you do not have. Answers 201:
{
"reference": "P-0001",
"patientId": "548712093",
"state": "created",
"verified": true,
"contactSaved": true,
"duplicatesFound": 0
}
The lab's duplicate check runs first: a patient whose details match one already on file is refused with 403 refused_by_policy. detail gives how many match but never who, because the match may be another organization's patient. A patient you created before is named on an order by your reference. Every patient has a mobile phone number: phone is required (ten digits, a US number, punctuation ignored; phoneType, if sent, must be Mobile). QDX reaches the patient by it to schedule the visit and ask for anything missing. An order that names a patient already on file with no mobile is refused; name them as { reference, phone } and it is saved.
Place an order
POST /v1/
{
"reference": "ORDER-1001",
"patient": {
"reference": "P-0001"
},
"testCodes": ["7600"],
"diagnosisCodes": [],
"clinician": {
"name": "Example, Casey",
"npi": "1234567893"
}
}
panelIds (optional, up to 20) names the panels the order came from. A panel is recorded with the order when all of its orderable tests are on it; the order itself is its testCodes.
Name the patient by patientId, by your patient.reference, or by a patient object with the same fields as POST /v1/patients to create them in the same call. collection is patient (the default: the patient takes the order to a patient service center) or draft. lab names the lab (optional; "national" is the default). testCodes are that lab's test codes; diagnosisCodes are optional. Only client billing is offered. Answers 201:
{
"reference": "ORDER-1001",
"state": "placed",
"requisition": "0000412",
"patientId": "548712093",
"patientCreated": false,
"documents": {
"requisition": "/v1/orders/ORDER-1001/requisition",
"holdLetter": "/v1/orders/ORDER-1001/hold-letter"
},
"verified": true
}
documents gives the paths of the requisition and the hold letter (the page the patient takes to the patient service center). A draft answers state: "draft" and documents: null.
state here is the outcome of the request (placed or draft), not an order state; read the order's state with orders.status (see Order states).
Placing with the lab can follow the answer by a few minutes. Until it has, state is still placed (QDX has accepted the order), requisition is pending:<your reference>, patientId is pending, verified is false, and the documents answer 404 not_found (not ready yet). orders.status shows prepared until then, and held with the requisition once the order is placed.
Some tests ask questions that must be answered when the order is placed (for example the specimen source). GET /v1/tests/{code} lists them; answer in answers, keyed by test code and question code: "answers": { "39448": { "86029035": "AN" } }. An unanswered required question, or an answer to a question the test does not ask, is refused.
While an invoice from QDX is unpaid past its grace period, live orders are refused with 403 refused_by_policy ("Live ordering is paused: …"). Test mode is unaffected. Paying the invoice resumes live ordering at once.
Estimate an order
POST /v1/
{
"testCodes": ["7600"],
"collection": "patient"
}
What the order would cost, before it is placed. testCodes lists 1 to 50 test codes; collection is patient or draft, and the answer is the same for both. Nothing is sent to the lab, and the answer is the same in test and live mode. Answers 200:
{
"asOf": "2026-10-09",
"tests": [
{
"code": "7600",
"title": "Lipid Panel, Standard",
"priceCents": 494
},
{
"code": "899",
"title": null,
"priceCents": 371
}
],
"testsCents": 865,
"collectionCents": 1000,
"includesSpecimenCollection": true,
"totalCents": 1865,
"byBankAccountCents": 1828,
"byCardCents": 1865,
"complete": true,
"unpriced": [],
"collectionUnpriced": false,
"mode": "test"
}
(This answer is for "testCodes": ["7600", "899"].)
asOfthe date of the prices.testseach test with itstitle(nullwhen QDX has no title for it yet) andpriceCents, its price ordered on its own (nullwhen it has no price yet).testsCentsthe tests' prices added up.collectionCents: the specimen collection fee, once per order.totalCentsistestsCentspluscollectionCents.includesSpecimenCollectionalways true. Every estimate is for a visit to a patient service center, the way QDX orders are collected.totalCentsthe order's total.byBankAccountCentsandbyCardCentsare what it comes to by each payment method, as if it were the only order on the month's invoice (nullfor a method not offered to you).completefalse when a test, or the collection itself, has no price yet. The tests are listed inunpriced;collectionUnpricedis true when the collection has no price yet. The totals are thennull.
The invoice charges the order's actual total, which can differ from the estimate when the lab's price changes before the visit.
Edit an order
PATCH /v1/
{
"testCodes": ["7600"],
"reportComments": "fasting"
}
Any of testCodes, diagnosisCodes, reportComments (up to 60 characters), clinicianNpi (an onboarded clinician the lab has registered), and answers for tests the edit adds. The order stays on hold with the same requisition, and its documents are produced again. Answers 200:
{
"reference": "ORDER-1001",
"state": "placed",
"requisition": "0000412",
"changed": ["testCodes"],
"documents": {
"requisition": "/v1/orders/ORDER-1001/requisition",
"holdLetter": "/v1/orders/ORDER-1001/hold-letter"
},
"verified": true
}
state is placed, or draft with documents: null for a draft; as when placing, it is the outcome of the request. Only a draft, held or unsynchronized order can be edited (see Order states). Some orders cannot be edited at all: the answer is 403 refused_by_policy saying so, and you cancel the order and place a new one. While live ordering is paused for an unpaid invoice, edits are refused too; cancelling still works.
Cancel an order
POST /v1/
{
"reason": "patient no longer needs the test"
}
reason is required: up to 255 characters, no semicolon. A draft, held, unsynchronized, prepared or failed order can be cancelled (see Order states). A draft, or an order not yet placed with the lab, is cancelled at once, and so is an order placed with the lab where the lab allows it. The answer is 200:
{
"reference": "ORDER-1001",
"state": "cancelled",
"requisition": "0000412",
"reason": "patient no longer needs the test",
"verified": true
}
Otherwise QDX cancels the order with the lab: the answer is 202, with state: "held", cancellation: "requested", verified: false and a meaning for people. orders.status shows held until the lab confirms, then cancelled. An order that has results can no longer be cancelled: the specimen was collected, and the request is refused with 403 refused_by_policy.
Order by HL7
POST /v1/
For an EMR that orders labs through a lab interface. The body is one HL7 v2 ORM^O01 message (any content type); the answer is an HL7 ACK (application/hl7-v2). It places or cancels the same order POST /v1/orders does, under the same credential and rules, in test mode with a test credential.
MSH-10is the message's idempotency key: the same message sent again gets the same answer.ORC-1NWplaces an order;ORC-2is its reference.PID-3(orPID-2) is your patient reference, with name, date of birth, sex and address, andPID-13the patient's mobile number (required); the reference alone names a patient you onboarded.OBR-4is each test's code at the lab, one OBR per test.ORC-12(orOBR-16) is the clinician asNPI^last^first. AnOBXunder an OBR answers that test's question (OBX-3the question code,OBX-5the answer).DG1-3diagnoses are optional.ORC-1CAcancels the orderORC-2names.MSA-1:AAplaced or cancelled (MSA-3names the requisition);AErefused, with the reason (an HTTP 503 means try again later);ARnot an ORM^O01 this interface takes (HTTP 400).
Order states
orders.status, orders.statuses and the order.status_changed webhook report an order in one of these states. A final state does not change again.
| state | meaning | final | can be changed |
|---|---|---|---|
draft | saved, not yet placed with the lab | no | edit, cancel |
held | placed with the lab and waiting for the patient's visit to a patient service center | no | edit, cancel |
filed | placed with the lab and waiting for collection or processing | no | no |
received | the lab has the specimen; no test has been performed yet | no | no |
partial | some of the ordered tests have reported; others have not | no | no |
resulted | the lab has reported | yes | no |
cancelled | cancelled before the lab received the specimen | yes | no |
unsynchronized | placed with the lab; its status has not been read back yet | no | edit, cancel |
gone | no longer listed by the lab: cancelled there, or older than the order history QDX reads | no | no |
prepared | accepted by QDX and not yet placed with the lab; the lab has no record of it yet | no | cancel |
failed | the lab could not take the order; nothing was placed | no | cancel |
unknown | the lab reported a status that could not be interpreted | no | no |
Only draft, held and unsynchronized orders can be edited, and some cannot be edited at all (see Edit an order). Those and prepared and failed orders can be cancelled. An order in any other state can no longer be changed. unsynchronized is never sent as a webhook.
orders.list and orders.for-patient give state in the lab's own words, as the lab lists the order. Each maps to one state above; watch orders.status (or the webhook) for QDX's states, and read the list's word only alongside it:
orders.list state | QDX state |
|---|---|
onhold | held |
received | received |
partial | partial |
final | resulted |
cancelled | cancelled |
draft | draft |
scheduled | scheduled |
filed | filed |
Reads
POST /v1/operations/{name} with the request fields as a JSON body. Every operation answers only with your organization's records; freshness is the oldest the data can be, and older data is refused as stale.
| operation | what it answers | request fields | freshness |
|---|---|---|---|
clinicians.references | The clinicians this organization has onboarded, and whether each can be named on an order | npi | 1 day |
orders.for-patient | Every lab order placed for one patient | patientId | 1 h |
orders.hold-letter | The hold letter for an order this organization placed, which the patient takes to the patient service center | reference | 1 h |
orders.list | Lab orders placed within a date range | from, to | 1 h |
orders.requisition | The requisition document for an order this organization placed, as the patient carries it | reference | 1 h |
orders.status | The state of an order this organization placed, and whether its results have arrived | reference | 1 h |
orders.statuses | The state of every order this organization placed | none | 1 h |
patients.get | One patient by identifier | patientId | 1 day |
patients.list | Every patient your organization has ordered for | limit | 1 day |
patients.references | The patients this organization has onboarded, and the identifier to order for each | reference | live |
patients.search | Patients this organization has ordered for, matching a name, identifier or date of birth | term, dob, limit | 1 day |
results.for-patient | Every lab result attached to one patient's chart | patientId | 1 h |
results.get | One lab result by identifier | resultId | 1 h |
results.list | Lab results reported within a date range | from, to | 1 h |
results.report | The lab report document for one result, as the ordering clinician would read it | resultId | 1 h |
results.unattached | Your organization's results the lab has not matched to a patient | none | 1 h |
GET /v1/catalog describes the API's operations, errors and labs as JSON. It is not the test catalog: tests are GET /v1/tests (see Tests).
The answer
Every operation answers 200 with the same wrapper around its data:
{
"operation": "orders.list",
"version": "v1",
"scope": "organization",
"servedAs": "organization",
"narrowedToClinician": null,
"requestingOrganization": "your-organization",
"freshness": {
"synchronizedAt": "2026-09-14T11:59:00.000Z",
"ageSeconds": 60,
"guaranteeSeconds": 3600
},
"data": [
{
"orderId": "po-1",
"requisition": "0000212",
"patientId": "900000001",
"orderedAt": "2026-09-14 09:30:00",
"state": "Pending",
"orderingProviderNpi": "1234567893",
"testCodes": ["7600"],
"trust": null
}
]
}
operation: the operation asked.version: its version (live only).scope: alwaysorganization: the answer holds only your organization's records.servedAs: alwaysorganizationfor your token.narrowedToClinician: the NPI when your token belongs to one clinician, so you see only that clinician's orders and results;nullfor a practice token.requestingOrganization: your organization.freshness(live only): when the data was last read from the lab (synchronizedAt), how old it is (ageSeconds), and the oldest it may be for this operation (guaranteeSeconds, the table's freshness column). Older data is refused asstale.mode(test only):"test". A live answer has nomode, and a test answer has noversionorfreshness. Don't key on any of the three to tell test from live: your token (qrt_orqrp_) already says which it is.data: the answer, an object or a list as below. Asking for one record that is not yours, or does not exist, is404 not_found; a list with nothing in it is[].
trust on a record is null, or data-quality signals about it: { trustworthy, signals } on a patient, { complete, signals } on an order, { matchable, signals } on a result.
What each operation answers
Patients
patients.list, patients.search (limit 25 when absent) and patients.get answer patients (patients.get one, the others a list), each { patientId, lastName, firstName, dateOfBirth, sex, trust }.
patients.references answers the patients you onboarded, each { reference, patientId, requestedAt, boundAt, state }. state is prepared (being created at the lab; patientId is null), ready, failed (retried) or abandoned (not retried; create the patient again).
clinicians.references answers the clinicians you onboarded, each with npi, registryName, submittedName (the name you sent), reference, state (verified, prepared or registered; each can be named on an order), requestedAt and registeredAt.
Orders
orders.list and orders.for-patient answer orders as the lab lists them (the sample above), each { orderId, requisition, patientId, orderedAt, state, orderingProviderNpi, testCodes, trust }. Here state is the lab's own word for the order's progress, not one of the order states.
orders.status (one order, by your reference) and orders.statuses (every order) answer:
{
"reference": "T-ORDER-1",
"requisition": "T0000001",
"state": "resulted",
"createdAt": "2026-10-09T22:44:24.989Z",
"meaning": "the lab has reported",
"inStateSeconds": 0,
"observedStates": ["held", "received", "resulted"],
"timeline": [
{ "state": "held", "at": "2026-10-09T22:44:24.989Z" },
{ "state": "received", "at": "2026-10-09T22:44:24.995Z" },
{ "state": "resulted", "at": "2026-10-09T22:44:24.995Z" }
],
"stuck": false,
"resultsDelivered": ["TR0000001"]
}
state is one of the order states and meaning says it in words. inStateSeconds is how long the order has been in it; observedStates and timeline are the states QDX saw, oldest first (a state that passed between two checks is missing). createdAt is when QDX accepted the order; placedAt, when present, when it was placed with the lab. cancellation: "requested" is present while QDX cancels it with the lab. stuck is true when the order needs attention: it has been in its state longer than expected, or placing it failed. resultsDelivered lists the order's resultIds.
orders.requisition and orders.hold-letter answer the document as { reference, requisition, patientId, contentType, byteSize, sha256, renderedAt, contentBase64 }: the PDF in base64, with its size and SHA-256 hash. GET /v1/ and /hold-letter give the same PDF as bytes.
Results
results.list, results.get, results.for-patient and results.unattached answer results, each { resultId, lab, patientId, lastName, firstName, dateOfBirth, accession, requisition, reportedAt, status, orderingProviderNpi, attachedToChart, trust }. lab is the lab that performed it ("national"); status is the report's status as the lab gives it (Final, Preliminary); attachedToChart is false when the lab has not matched the result to a patient (patientId is then null), which is what results.unattached lists. results.get adds the values, once QDX has them:
"tests": [
{
"testCode": "7600",
"name": "LDL-CHOLESTEROL",
"value": "118",
"unit": "mg/dL (calc)",
"range": "<100",
"flag": "H",
"loinc": "13457-7",
"labCode": "25016900",
"status": "F"
}
]
testCode is the test ordered; labCode is the lab's own code for the value and loinc its LOINC code (null when there is none); flag is the lab's flag (N, H, L and so on, or null); status is the value's status (F final). tests is absent until the values arrive; the report always has every value.
results.report answers the report PDF as { resultId, requisition, patientId, contentType, byteSize, sha256, retrievedAt, reportedAt, status, amendment, contentBase64 }. amendment is unchanged when the result has not changed since the PDF was retrieved, or unverifiable when that cannot be checked. GET /v1/ gives the same PDF as bytes.
Changes
GET /v1/ is a feed of what has changed in your organization's records, oldest first. It is live only: a test token is refused (see Test mode). Start with cursor empty (or 0); limit is 1 to 1000 (100 when empty). Answers 200:
{
"changes": [
{
"sequence": 1042,
"entity": "results",
"identity": "8812345",
"kind": "created",
"contentHash": "9f2c…",
"recordedAt": "2026-10-03T18:00:00.000Z"
}
],
"entities": [],
"nextCursor": 1042,
"more": false,
"scopedTo": "your-organization",
"servedAs": "organization"
}
Each change names a record (entity and identity) and whether it was created or updated; read the record itself with the matching operation. Send nextCursor as the next cursor, and ask again at once while more is true. Changes to records your organization cannot see are left out of the page, so a page can hold fewer than limit changes, or none, while more is still true; nextCursor still moves past them. Keep asking while more is true. Save the cursor only after you have handled the page: a change can then be seen twice but never missed, and contentHash tells a repeat from a new change. entities says, for each kind of record, how old the data is (ageSeconds) and whether it can be read now (servable, with a reason when not). scopedTo names your organization.
Health
GET /v1/health is for when reads are refused as stale. refreshing is true when the data is being refreshed, so stale refusals clear on their own (retry with backoff); false means they will not, and QDX should be told. servable lists the kinds of record that can be read now, and consumerAction says what to do. Whether the API and the lab are up is the status page (see Basics).
Documents
GET /v1/ and GET /v1/ return PDFs. The patient takes the hold letter to the patient service center.
Result formats
A result in other forms, each for your organization's results only:
GET /v1/the report PDF.results/ {resultId}/ report GET /v1/an HL7 v2 ORU^R01 message QDX builds from the result's values: each value's OBX-3 carries its LOINC code where one exists, with the lab's own code alongside. In test mode, a message of the same shape marked as a test.results/ {resultId}/ hl7 GET /v1/a FHIR R4 Bundle QDX builds from the result: Patient, DiagnosticReport (with the PDF) and one Observation per value, coded in LOINC where a code exists, with the lab's own code alongside. Eachresults/ {resultId}/ fhir fullUrlis aurn:uuid:that stays the same for the result, so a resource sent again can be matched. Values and reference ranges areQuantitys in UCUM (systemhttp://unitsofmeasure.orgwith acode, and the lab's unit asunit);referenceRangehaslowand/orhighand the range astext. The DiagnosticReport's code text is the ordered tests' names, separated by;(names have commas of their own), and each test code's coding carries its name asdisplay. QDX's own code systems and identifiers:https:/: the lab's test code (DiagnosticReport)./ qdx.systems/ fhir/ CodeSystem/ lab-test-code https:/: the lab's code for a value (Observation)./ qdx.systems/ fhir/ CodeSystem/ lab-result-code https:/,/ qdx.systems/ fhir/ NamingSystem/ lab-result-id lab-requisitionandlab-accession(same prefix): the result, its requisition and its accession, on the DiagnosticReport.https:/: your order reference, on the DiagnosticReport./ qdx.systems/ fhir/ NamingSystem/ organization/ {organization}/ order https:/: your patient reference, on the Patient;/ qdx.systems/ fhir/ NamingSystem/ organization/ {organization}/ patient https:/(the lab's patient ID) when QDX has no reference for the patient./ qdx.systems/ fhir/ NamingSystem/ lab-patient-id - In test mode each resource carries the tag
https://qdx.systems/fhir/tagtest-mode.
GET /v1/the result as plain text.results/ {resultId}/ text GET /v1/every delivery of the result (see below).results/ {resultId}/ deliveries
Deliveries
The results router delivers each result within about a minute of it arriving, in test mode too, and records one delivery per destination. The QDX portal is always one of them. A test result read soon after it is resulted shows no deliveries yet; ask again a minute later. GET /v1/ answers:
{
"resultId": "TR0000001",
"deliveries": [
{
"id": "dlv_53f6bc5c-b4e8-441e-8dd4-871e5d1f3f40",
"channel": "portal",
"destinationId": "dst_142bd94f-cfae-4f5c-bf62-a04d6f000e9d",
"state": "delivered",
"attempts": 1,
"lastError": null,
"proof": {
"url": "https://qdx.systems/app/#/results/TR0000001?mode=test"
},
"createdAt": "2026-10-09T22:44:25.146Z",
"updatedAt": "2026-10-09T22:44:25.149Z"
}
]
}
channel is one of the channels in Where your results go, plus webhook and emr; destinationId is the destination it went to. state is one of pending, sent, delivered, failed, gave_up: sent means handed to a carrier that confirms later (a fax), failed is retried with backoff, and gave_up is final. attempts counts tries; lastError says why the last one failed; proof is what the channel gave back (for the portal, the result's link). The webhook delivery is recorded only when you have set a webhook URL, and the EMR delivery only for an order placed from a chart.
Where your results go
Every result is delivered on each channel you choose, with retries and a record of each delivery. The portal and your webhook are on by default; the chart an order was placed from receives its result when the EMR connection files results. Add others:
GET /v1/: yours, with whether each is verified.delivery/ destinations POST /v1/withdelivery/ destinations { channel, config, label?, clinicianNpi? }. WithclinicianNpiit replaces the practice's destinations on that channel for that clinician's orders. Channels:portal,pdf,txt,hl7-api,fhir-api(available through the routes above);hl7-push({ transport: "https", url },{ transport: "sftp", host, username, password | privateKey, directory }or{ transport: "mllp", host, port }over TLS);fhir-push({ baseUrl, auth? });email({ to, attach?: "pdf" | "txt" }: a notice with a link unless you choose to attach);direct({ to }, your Direct address: the result with its report, by Direct secure message);fax,smsandvoice({ to }in E.164; text and calls carry a notice only).- A destination is used once verified: an endpoint when QDX can reach it, an email address when you enter the code QDX sends to it (
POST /v1/withdelivery/ destinations/ {id}/ confirm { code }), and a Direct address the same way. Resend the code, or re-check an endpoint, withPOST /v1/.delivery/ destinations/ {id}/ verify PATCH /v1/withdelivery/ destinations/ {id} { active }or{ label };DELETE /v1/turns one off. Passwords, keys and tokens are stored encrypted and shown back only asdelivery/ destinations/ {id} (stored).
Webhooks
Set your webhook URL (https) in the portal, under Settings, Webhook. Saving it shows the webhook's signing secret once; saving a URL again issues a new secret. QDX calls the URL with POST and a JSON body { id, type, createdAt, data }; data.event repeats type. In test mode data also carries "mode": "test".
result.available is sent once per result:
{
"id": "6f1c2b9e-1d3a-4c55-9a0e-2b7f8c1d4e5a",
"type": "result.available",
"createdAt": "2026-10-03T18:00:00.000Z",
"data": {
"event": "result.available",
"resultId": "8812345",
"requisition": "0000412",
"reference": "ORDER-1001",
"status": "Final",
"reportedAt": "2026-10-03T13:58:00-04:00",
"links": {
"result": "/v1/operations/results.get",
"report": "/v1/results/8812345/report"
}
}
}
status is the report's status as the lab gives it, such as Final or Preliminary; compare it without regard to case. links is { result, report }: the operation that reads the result (send it { "resultId": ... }) and the report PDF.
order.status_changed: data is { event, reference, requisition, state, meaning }, with state one of the order states above. It is sent when a change is seen; checks run every minute, so states that pass quickly can be skipped. Treat it as the latest state.
Headers: QDX-Event, QDX-Delivery (the event id; use it to ignore duplicates), and QDX-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of <t>.<raw body> with your webhook's signing secret. Verify it, and reject timestamps more than five minutes old:
import {
createHmac,
timingSafeEqual,
} from "node:crypto";
function verify(secret, rawBody, header) {
const { t, v1 } = Object.fromEntries(
String(header)
.split(",")
.map((part) => part.split("=")));
if (!t || !v1) return false;
const now = Date.now() / 1000;
const age = Math.abs(now - Number(t));
if (!(age <= 300)) return false;
const hmac = createHmac("sha256", secret);
const expected = hmac
.update(`${t}.${rawBody}`)
.digest();
const received = Buffer.from(v1, "hex");
return expected.length === received.length
&& timingSafeEqual(expected, received);
}
Answer 2xx to accept. Anything else, or no answer in 10 seconds, is retried after 1 minute, doubling to at most 6 hours, for 12 attempts (about 21 hours); then the event is marked failed and QDX is alerted.
Ordering from your EMR
QDX's ordering app opens inside the patient's chart in Epic, Oracle Health, athenahealth and Canvas (SMART on FHIR, R4). In Epic it can file results back into the chart as a note; in Oracle Health, the lab report (PDF) and each value; in Canvas, each result as a lab report. Your EMR team's setup steps:
Any other EMR with a lab interface can order by HL7 (see Order by HL7).
What is stable in version 1
These will not change within /v1:
- The paths and methods in this document, and the request fields they accept.
- The response fields documented here, their types, and what they mean. New fields may be added.
- Error categories and their HTTP statuses. New categories may be added; treat an unknown one as not retryable.
- The order states above and their meanings. New states may be added; treat an unknown one as not final.
- Webhook event types, the envelope
{ id, type, createdAt, data }, and the signature scheme. - Patient identifiers, requisition numbers and your references, once issued.
A change that breaks any of these gets a new path, /v2, and /v1 keeps working alongside it.