REST API
The Eirly REST API provides programmatic access to manage referrals, results, tests, biomarkers, and bundles, and to find the collection centres where patients can be tested. All API requests require authentication via OAuth 2.0.
Base URL: https://api.eirly.health
Current Version: v1
For interactive API documentation and testing, visit our Swagger Documentation
API Versioning
The API supports versioning through both URL paths and custom headers:
- v1.0.0 (Default) - Blood tests only, single referral per request
- v1.1.0 - Supports bundles, Medicare ordering, and multiple referrals per request
To use a specific version, include the Accept header:
Accept: application/vnd.bgt.v1.1.0+json
Authentication
All API endpoints require authentication. Include your access token in the Authorization header:
Authorization: Bearer YOUR_ACCESS_TOKEN
Timeouts
The ordering endpoint /referrals can take upwards of 10 seconds to complete, so please allow for this to avoid duplicate orders being sent if you have a retry strategy in place.
Core Resources
Referrals
Referrals represent test orders for patients. A referral contains patient information, requested tests, and metadata.
The referral payload will return an origin property, possible values are:
export enum ReferralOrigin {
ConsumerWebsite = 'CONSUMER_WEBSITE',
BundlePurchase = 'BUNDLE_PURCHASE',
Api = 'API',
StoreIntegration = 'STORE_INTEGRATION',
Unknown = 'UNKNOWN',
}
The referral status property progresses through the following values across the referral lifecycle:
export enum ReferralStatus {
PendingVerification = 'PENDING_VERIFICATION', // awaiting verification before issue
Created = 'CREATED', // created but not yet issued
Issued = 'ISSUED', // PDF generated and ready to deliver
ScheduledReissue = 'SCHEDULED_REISSUE', // a new PDF is being regenerated (e.g. after PATCH)
PartialResults = 'PARTIAL_RESULTS', // some but not all results returned
Complete = 'COMPLETE', // all results returned
InsufficientData = 'INSUFFICIENT_DATA', // unable to process due to missing data
Refunded = 'REFUNDED', // referral refunded
TestFailed = 'FAILED_TEST', // one or more tests failed at the lab
BillingFailed = 'BILLING_FAILED', // billing could not be completed
Transferred = 'TRANSFERRED', // referral transferred to another provider
}
Only referrals with status CREATED or ISSUED can be updated via PATCH. See Update Referral for details.
Create Referral
POST /v1/referrals
Creates a new referral for a patient.
Request Body:
{
"firstName": "John",
"lastName": "Vein",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "123 Example Way",
"addressLine2": null,
"postCode": "4000",
"city": "Brisbane",
"country": "Australia",
"state": "QLD"
},
"dob": "1980-01-01",
"gender": "male",
"phone": "0412345678",
"tests": ["tft", "free-testosterone"],
"metadata": {
"internalId": "1234"
},
"reference": "48213907",
"referenceNumber": "12345678",
"emailReferralToCustomer": true
}
Optional Fields:
| Field | Type | Default | Description |
|---|---|---|---|
emailReferralToCustomer | boolean | true | Controls whether Eirly emails the referral PDF directly to the customer. Set to false if you want to deliver the referral to the customer yourself — when disabled, Eirly will not send the referral email and you are responsible for getting it to the patient. |
For v1.1.0 - Bundle Support:
{
"firstName": "John",
"lastName": "Vein",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "123 Example Way",
"postCode": "4000",
"city": "Brisbane",
"country": "Australia",
"state": "QLD"
},
"dob": "1980-01-01",
"gender": "male",
"phone": "0412345678",
"bundleId": "56ce2120-5480-4657-9e47-7ae4ecf9229b",
"metadata": {
"internalId": "1234"
}
}
Choosing between bundleId and tests:
On v1.1.0, a referral can be created from a pre-configured bundle, an ad-hoc list of tests, or both. At least one of tests, bundleId, or medicareTests must be provided — sending none will return a 400 error.
| Provided | Behaviour |
|---|---|
tests only | The referral is created with exactly the tests you listed. |
bundleId only | The referral is created with the full set of tests defined on that bundle. The bundle's productId / productName will be attached to the referral. |
bundleId and tests | The referral includes every test in the bundle plus any additional tests you pass in tests. Use this when you want to extend a standard bundle on a per-patient basis (e.g. add vitamin-d to a "Men's Health" bundle). Duplicate tests are de-duplicated server-side. |
Notes:
bundleIdis only accepted on v1.1.0 and above. Sending abundleIdagainstv1.0.0will return a400error directing you to upgrade the version header.- The response's
testsarray reflects the fully-resolved list of tests on the referral, regardless of whether they came from a bundle, thetestsfield, or both.
For v1.1.0 - Medicare ordering:
A single request can also order Medicare tests by passing medicareTests. These tests are ordered under a doctor's provider number and claimed against Medicare — they are not billed to your organisation. Commercial tests (tests / bundleId) continue to be billed exactly as before.
medicareTests is not a guarantee that the patient pays nothingThe field name says how the test is ordered — under Medicare, with the practitioner's provider number. Whether Medicare covers a given test in full is determined by the collecting laboratory and the patient's Medicare eligibility at the time of collection, and the patient may still be charged a gap or the full fee. Do not present these tests to your customers as guaranteed to be free.
When medicareTests is provided, the request produces two separate referrals — one commercial referral for tests/bundleId and one Medicare referral for medicareTests (issued on a Medicare request form). The response is therefore an array of referrals, and the customer receives a single email with both referral PDFs attached.
orderingPractitioner.providerNumber is not accepted as a free-form value. Before it can be used in an API request, the provider must be configured on the Eirly platform:
- The provider number must be registered against the organisation that owns your API credentials. All API access is scoped to a single organisation, and the provider number you send must belong to a practitioner registered in that organisation. A provider number that isn't registered for your organisation is rejected with
INVALID_PROVIDER_NUMBER. - That practitioner must have a signature on file for the organisation. The signature is drawn and stored on the platform (Settings → Provider Numbers) — it cannot be supplied through the API — and it is what gets printed on the Medicare referral. If no signature is on file for the provider number, the request is rejected with
MISSING_PROVIDER_SIGNATURE.
This is a one-time, per-practitioner, per-organisation setup done in the app, not via the API. See the Practitioners → Medicare guide for the full setup flow.
{
"firstName": "Jane",
"lastName": "Doe",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "456 Sample Street",
"postCode": "2000",
"city": "Sydney",
"country": "Australia",
"state": "NSW"
},
"dob": "1992-05-15",
"gender": "female",
"phone": "0498765432",
"tests": ["free-testosterone"],
"medicareTests": ["vitamin-d", "iron-studies"],
"orderingPractitioner": {
"name": "Dr Bill Smith",
"address": "123 Example St, Sydney NSW 2000",
"providerNumber": "2426621B"
},
"medicareCardNumber": "21234567011",
"dvaNumber": "QX123456",
"clinicalNotes": "Fatigue, ?anaemia",
"referenceNumber": "55667788"
}
Medicare fields:
| Field | Type | Required | Description |
|---|---|---|---|
medicareTests | string[] | — | Tests to order under Medicare using the practitioner's provider number. Produces a separate Medicare referral. Not a guarantee that the patient pays nothing. |
orderingPractitioner | object | when medicareTests is present | The ordering doctor: name, address, and providerNumber (all required). The providerNumber must be registered — with a signature — against your organisation (see prerequisite above). |
medicareCardNumber | string | when medicareTests is present | Patient's Medicare card number including the IRN — 11 digits. The IRN is the single digit printed beside the patient's name on the card; see the note below. On a commercial order (no medicareTests) the 10-digit card number is accepted. |
dvaNumber | string | — | Patient's DVA file number, if applicable. |
clinicalNotes | string | — | The ordering doctor's clinical notes for the Medicare referral. Kept separate from Eirly's auto-generated clinical notes. |
Notes:
- Medicare ordering requires v1.1.0 and above. Sending
medicareTestsagainstv1.0.0returns a400error directing you to upgrade the version header (thev1.0.0single-referral response cannot represent two referrals). - The provider number must be registered against your organisation. The organisation is determined by the API credentials used for the request, and the
providerNumberyou send must belong to a practitioner registered in that organisation. If it isn't, the request is rejected withINVALID_PROVIDER_NUMBER. - A signature must be on file for that provider number. Signatures are captured on the platform (Settings → Provider Numbers), not via the API. A provider number with no signature on file is rejected with
MISSING_PROVIDER_SIGNATURE. OmittingorderingPractitionerentirely returnsMISSING_PROVIDER_NUMBER. medicareCardNumbermust include the IRN. The 10 digits printed across the top of a Medicare card identify the card, and everyone named on it shares them. The IRN — the single digit printed beside each person's name — is the only part that identifies one individual, so an order under Medicare has to carry it. Send all 11 digits. A 10-digit number withmedicareTestsreturns a400withINVALID_PAYLOAD.- Medicare card numbers are checksum validated on every request, whether or not the order is under Medicare. A number that fails the checksum returns a
400. On a commercial order the card number is optional and the IRN is not required — there is no "which person on the card" question to answer — but if you send one it still has to be valid. orderingPractitioner.providerNumbermust also be a valid 8-character Medicare provider number. Malformed identifiers return a400.- Only pathology (blood) tests can be ordered under Medicare. Including a non-eligible test in
medicareTestsis rejected withINVALID_MEDICARE_TEST. - The same test id may appear in both
testsandmedicareTests— ordering the same test as both commercial and Medicare is valid and yields it on both referrals. Duplicates within a single list are de-duplicated. - You may send
medicareTestson its own (notests/bundleId) to create a Medicare referral only. - Result routing is advisory, not a prerequisite. The Medicare referral is always created regardless of whether the provider number has result routing configured. The Medicare referral in the response carries a
routingMessagedescribing whether (and from which labs) the practitioner's Medicare results will route back to them automatically, and — when routing isn't set up — how to configure it. See theroutingMessagefield in the response table below.
Response (200 OK):
{
"uuid": "254fa374-6d05-4a41-bfd8-254ba2039049",
"status": "ISSUED",
"object": "referral",
"tests": ["tft", "free-testosterone"],
"metadata": {
"internalId": "1234"
},
"productId": null,
"productName": null,
"origin": "API",
"person": {
"firstName": "John",
"lastName": "Vein",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "123 Example Way",
"addressLine2": null,
"postCode": "4000",
"city": "Brisbane",
"country": "Australia",
"state": "QLD"
},
"phone": "0412345678",
"dob": "1980-01-01",
"gender": "male"
},
"referralType": "blood",
"reference": "48213907",
"referenceNumber": "12345678",
"referralAttachment": "https://api.eirly.health/download/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlVXVpZCI6IjJhNGRkYjALTk5MItNDUxMy1iMTAxLTMzMjBmZmYzMzAwMSIsInByb2ZpbGVVdWlkIjoiMTQxZjQ5NTgtjYzJkYi00NDY1LIwMTEtODc3MmZmZjQzMzU3IiwicmFuZG9tVmsdWUiOiJBTHJrOXNYM01iWnFXcGc4UFNRdGZmS3Fd6dGRzZ24iLCJvcmdhbmljzYXRpb25VdWlkIjoiNWQ0NDkxOTQtYzVjMS00DUzLWIyNjTQtZGRhNzg0MjdkZjllIiwiZm9y2VEb3dubG9hZCI6dHJ1ZSwiaWF0IjjoxNzc5NDI2MjExLCJleHAiOjdE3Nzk0MjcxMTF9.x7Hj2vP9aQmLZc5dRngK4tUyB1eF6oI3wJxN8sVrTYg",
"emailSentAt": "2024-06-04T05:29:09.385Z",
"createdAt": "2024-06-04T05:29:06.508Z",
"updatedAt": "2024-06-04T05:29:09.385Z",
"deletedAt": null,
"resultsUuid": null,
"medicare": false,
"orderingPractitioner": null,
"medicareCardNumber": null,
"dvaNumber": null,
"orderingPractitionerClinicalNotes": null,
"routingMessage": null
}
Notable response fields:
| Field | Type | Description |
|---|---|---|
reference | string | Our unique reference for this referral (8 digits). Distinct from referenceNumber, which is the value you supplied on create. |
referralAttachment | string (url) | null | Signed URL to the referral PDF. null until the PDF has been generated (e.g. while status is CREATED or SCHEDULED_REISSUE), and null for deleted referrals. URLs are time-limited — re-fetch the referral to get a fresh URL. |
updatedAt | string (ISO 8601) | Time the referral record was last modified. Updated whenever the referral status changes or fields are edited via PATCH. |
deletedAt | string (ISO 8601) | null | Set when the referral has been soft-deleted via DELETE; null otherwise. |
emailSentAt | string (ISO 8601) | null | Time the referral email was dispatched to the customer. null if emailReferralToCustomer was false, or before delivery has occurred. |
resultsUuid | string (uuid) | null | UUID of the associated result once results are available (i.e. once status reaches PARTIAL_RESULTS or COMPLETE). |
medicare | boolean | true if this referral was ordered under Medicare, false for a commercial referral. Not a statement about what the patient was ultimately charged. |
orderingPractitioner | object | null | The ordering doctor (name, address, providerNumber). Present on Medicare referrals only. |
medicareCardNumber | string | null | Patient's Medicare card number. Present on Medicare referrals only. |
dvaNumber | string | null | Patient's DVA file number. Present on Medicare referrals only. |
orderingPractitionerClinicalNotes | string | null | The ordering doctor's clinical notes. Present on Medicare referrals only. |
routingMessage | string | null | Advisory, human-readable summary of whether (and from which labs) the Medicare results will route back to the practitioner automatically. Advisory only — never blocks the order. Populated on the Medicare referral returned from Create; null on commercial referrals and on all other endpoints. |
Note: When a request includes
medicareTests, this endpoint returns an array of referrals (one commercial, one Medicare) rather than a single object.
Response (200 OK) — Medicare request (array):
[
{
"uuid": "254fa374-6d05-4a41-bfd8-254ba2039049",
"status": "ISSUED",
"object": "referral",
"tests": ["free-testosterone"],
"metadata": null,
"productId": null,
"productName": null,
"origin": "API",
"person": {
"firstName": "Jane",
"lastName": "Doe",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "456 Sample Street",
"addressLine2": null,
"postCode": "2000",
"city": "Sydney",
"country": "Australia",
"state": "NSW"
},
"phone": "0498765432",
"dob": "1992-05-15",
"gender": "female"
},
"referralType": "blood",
"reference": "48213907",
"referenceNumber": "55667788",
"referralAttachment": "https://api.eirly.health/download/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"emailSentAt": "2024-06-04T05:29:09.385Z",
"createdAt": "2024-06-04T05:29:06.508Z",
"updatedAt": "2024-06-04T05:29:09.385Z",
"deletedAt": null,
"resultsUuid": null,
"medicare": false,
"orderingPractitioner": null,
"medicareCardNumber": null,
"dvaNumber": null,
"orderingPractitionerClinicalNotes": null,
"routingMessage": null
},
{
"uuid": "9b1f3c7a-2e54-4d0b-8a61-1c2d3e4f5a6b",
"status": "ISSUED",
"object": "referral",
"tests": ["vitamin-d", "iron-studies"],
"metadata": null,
"productId": null,
"productName": null,
"origin": "API",
"person": {
"firstName": "Jane",
"lastName": "Doe",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "456 Sample Street",
"addressLine2": null,
"postCode": "2000",
"city": "Sydney",
"country": "Australia",
"state": "NSW"
},
"phone": "0498765432",
"dob": "1992-05-15",
"gender": "female"
},
"referralType": "blood",
"reference": "48213907",
"referenceNumber": "55667788",
"referralAttachment": "https://api.eirly.health/download/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"emailSentAt": "2024-06-04T05:29:09.385Z",
"createdAt": "2024-06-04T05:29:06.508Z",
"updatedAt": "2024-06-04T05:29:09.385Z",
"deletedAt": null,
"resultsUuid": null,
"medicare": true,
"orderingPractitioner": {
"name": "Dr Bill Smith",
"address": "123 Example St, Sydney NSW 2000",
"providerNumber": "2426621B"
},
"medicareCardNumber": "21234567011",
"dvaNumber": "QX123456",
"orderingPractitionerClinicalNotes": "Fatigue, ?anaemia",
"routingMessage": "Result routing isn't set up for provider number 2426621B, so Medicare results won't route back to you automatically. Set up result routing at https://app.eirly.health/org/your-org/settings/result-routing."
}
]
Get Referral
GET /v1/referrals/{uuid}
Retrieves a specific referral by UUID.
Response (200 OK): Same structure as Create Referral response
List Referrals
GET /v1/referrals
Returns all referrals for your organisation with pagination support.
Query Parameters:
count(integer, optional) - Number of results to return (max 20, default 20)cursor(string, optional) - Cursor for paginationsort(string, optional) - Sort order:ascordesc(default:asc)
Response (200 OK):
{
"cursor": {
"cursor": "MjAyNC0wNi0wNFQxMjo1OTowMS44MDJafGNyZWF0ZWRBdHxhc2M=",
"count": 20,
"resultCount": 5
},
"referrals": [
{
"uuid": "254fa374-6d05-4a41-bfd8-254ba2039049",
"status": "COMPLETE",
"object": "referral",
"tests": ["tft", "free-testosterone"],
"metadata": {
"internalId": "1234"
},
"productId": "prod_abc123",
"productName": "Comprehensive Health Check",
"origin": "API",
"person": {
"firstName": "John",
"lastName": "Vein",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "123 Example Way",
"postCode": "4000",
"city": "Brisbane",
"state": "QLD"
},
"phone": "0412345678",
"dob": "1980-01-01",
"gender": "male"
},
"referralType": "blood",
"reference": "48213907",
"referenceNumber": "12345678",
"referralAttachment": "https://api.eirly.health/download/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"emailSentAt": "2024-06-04T05:29:09.385Z",
"createdAt": "2024-06-04T05:29:06.508Z",
"updatedAt": "2024-06-04T05:29:09.385Z",
"deletedAt": null,
"resultsUuid": "d9d820a4-72e1-41bc-9fac-ffb23f18dfb4",
"medicare": false,
"orderingPractitioner": null,
"medicareCardNumber": null,
"dvaNumber": null,
"orderingPractitionerClinicalNotes": null,
"routingMessage": null
}
]
}
Update Referral
PATCH /v1/referrals/{uuid}
Updates person details and/or metadata on an existing referral. A new referral PDF will be generated after the update.
Only referrals with a status of CREATED or ISSUED can be updated. Attempting to update a referral with any other status (e.g. COMPLETE, PARTIAL_RESULTS, REFUNDED) will return an error.
All fields are optional — only the fields you include in the request body will be updated. Omitted fields remain unchanged.
Request Body:
| Field | Type | Description |
|---|---|---|
firstName | string | Patient's first name (min 2 chars) |
lastName | string | Patient's last name (min 2 chars) |
email | string | Patient's email address |
dob | string | Date of birth (format: YYYY-MM-DD) |
gender | string | male or female |
phone | string | Phone number (min 6 chars) |
address | object | Address object (see Create Referral) |
metadata | object | Key-value pairs (see Metadata Merging) |
Metadata Merging:
Metadata is merged with the existing metadata on the referral rather than replaced. To remove a key, set its value to null:
{
"metadata": {
"newKey": "new-value",
"existingKey": "updated-value",
"keyToRemove": null
}
}
Example — update name and email:
{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com"
}
Example — update address:
{
"address": {
"addressLine1": "456 New Street",
"addressLine2": "Unit 3",
"postCode": "2000",
"city": "Sydney",
"country": "Australia",
"state": "NSW"
}
}
Response (200 OK):
{
"uuid": "254fa374-6d05-4a41-bfd8-254ba2039049",
"status": "SCHEDULED_REISSUE",
"object": "referral",
"tests": ["tft", "free-testosterone"],
"metadata": {
"internalId": "5678"
},
"productId": "prod_abc123",
"productName": "Comprehensive Health Check",
"origin": "API",
"person": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"address": {
"addressLine1": "123 Example Way",
"addressLine2": null,
"postCode": "4000",
"city": "Brisbane",
"country": "Australia",
"state": "QLD"
},
"phone": "0412345678",
"dob": "1980-01-01",
"gender": "male"
},
"referralType": "blood",
"reference": "48213907",
"referenceNumber": "12345678",
"referralAttachment": "https://api.eirly.health/download/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"emailSentAt": "2024-06-04T05:29:09.385Z",
"createdAt": "2024-06-04T05:29:06.508Z",
"updatedAt": "2024-06-04T06:15:00.000Z",
"deletedAt": null,
"resultsUuid": null,
"medicare": false,
"orderingPractitioner": null,
"medicareCardNumber": null,
"dvaNumber": null,
"orderingPractitionerClinicalNotes": null,
"routingMessage": null
}
After a successful update, the referral status will change to SCHEDULED_REISSUE while a new PDF is generated. Once the PDF is ready, the status will return to ISSUED.
Delete Referral
DELETE /v1/referrals/{uuid}
Soft-deletes a referral. The referral will be marked as deleted but not permanently removed. The referralAttachment URL is cleared on deletion.
Response (200 OK):
{
"uuid": "254fa374-6d05-4a41-bfd8-254ba2039049",
"status": "COMPLETE",
"object": "referral",
"tests": ["tft", "free-testosterone"],
"metadata": {
"internalId": "1234"
},
"productId": "prod_abc123",
"productName": "Comprehensive Health Check",
"origin": "API",
"person": {
"firstName": "John",
"lastName": "Vein",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "123 Example Way",
"addressLine2": null,
"postCode": "4000",
"city": "Brisbane",
"country": "Australia",
"state": "QLD"
},
"phone": "0412345678",
"dob": "1980-01-01",
"gender": "male"
},
"referralType": "blood",
"reference": "48213907",
"referenceNumber": "12345678",
"referralAttachment": null,
"emailSentAt": "2024-06-04T05:29:09.385Z",
"createdAt": "2024-06-04T05:29:06.508Z",
"updatedAt": "2024-06-04T07:00:00.000Z",
"deletedAt": "2024-06-04T07:00:00.000Z",
"resultsUuid": "d9d820a4-72e1-41bc-9fac-ffb23f18dfb4",
"medicare": false,
"orderingPractitioner": null,
"medicareCardNumber": null,
"dvaNumber": null,
"orderingPractitionerClinicalNotes": null,
"routingMessage": null
}
Simulate Results (Staging Only)
PUT /v1/referrals/{uuid}
This endpoint is only available in the staging environment and will return 404 in production. It is intended for integrators testing end-to-end flows without waiting for a real lab to return results.
Generates a set of random dummy results for the referral, allowing you to exercise the result-related endpoints, webhooks, and your downstream handling code without involving a real pathology lab.
Query Parameters:
partial(flag, optional) — if present (e.g.?partial), only a partial set of results is generated and the referral transitions toPARTIAL_RESULTSinstead ofCOMPLETE. Useful for testing how your integration handlesPARTIAL_RESULTS→COMPLETEtransitions and the associated webhooks.
Examples:
PUT /v1/referrals/254fa374-6d05-4a41-bfd8-254ba2039049
PUT /v1/referrals/254fa374-6d05-4a41-bfd8-254ba2039049?partial
Response (200 OK): Returns the updated referral. Once results have been generated, resultsUuid will be populated and the corresponding result can be fetched via GET /v1/results/{resultsUuid}.
Results
Results contain biomarker data and test outcomes for completed referrals.
Get Result
GET /v1/results/{uuid}
Retrieves detailed results for a specific result UUID.
Response (200 OK):
{
"uuid": "d9d820a4-72e1-41bc-9fac-ffb23f18dfb4",
"referralUuid": "254fa374-6d05-4a41-bfd8-254ba2039049",
"status": "COMPLETE",
"object": "result",
"reference": "48213907",
"referenceNumber": "12345678",
"productId": "prod_abc123",
"productName": "Comprehensive Health Check",
"origin": "API",
"resultAttachment": "https://api.eirly.health/download/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmaWxlVXVpZCI6IjNiNmVjYzA4LTE0gmEtNDk4Ni1iYjYzLTdkgDJiZGZjNzU3ZSIsInByb2ZpbGVVdWlkIjoiMTQxZjQ5NTgtYzJkYi00NDY1LWIwMTEtODc3MmZmZjQzMzU3IiwicmFuZG9tVmFsdWUiOiJJWk5vN2VKOEFmcWM4bFpjS0lRcmt3YXlzZ1o2RjMiLCJvcmdhbmlzYXRpb25VdWlkIjoiNWQ0NDkxOTQtYzVjMS00NDUgLWIyNTQtZGRhNzg0MjdkZjllIiwiZm9yY2VEb3dubG9hZCI6dHJ1ZSwiaWF0IjoxNzc5NDI2MjExLCJleHAiOjE3Nzk0MjcxMTF9.A5jHeOt7W71IqN5sbxnK7jsYiZ9LO6NB7wK42F5aTCw",
"createdAt": "2024-06-04T05:30:08.699Z",
"updatedAt": "2024-06-04T05:30:08.699Z",
"deletedAt": null,
"resultDate": "2024-05-29T15:29:00.000Z",
"reviewedBy": null,
"person": {
"firstName": "John",
"lastName": "Vein",
"email": "john.vein@eirly.health",
"address": {
"addressLine1": "123 Example Way",
"postCode": "4000",
"city": "Brisbane",
"state": "QLD"
},
"phone": "0412345678",
"dob": "1980-01-01",
"gender": "male"
},
"metadata": {
"internalId": "1234"
},
"biomarkerSummary": {
"normal": 18,
"abnormal": 2,
"critical": 0,
"total": 20
},
"biomarkers": [
{
"commonName": "Active Vitamin B12",
"description": "Active Vitamin B12 (Holotranscobalamin) is the bioavailable portion...",
"formattedValue": "2 pmol/L",
"id": "active-vitamin-b12",
"isAbnormal": false,
"name": "Active Vitamin B12",
"object": "biomarker",
"unit": "pmol/L",
"mostCommonUnit": "pmol/L",
"sampleType": "blood",
"notes": "Sample was collected fasting. Serum separator tube used.",
"category": {
"id": "vitamins-and-minerals",
"name": "Vitamins & Minerals",
"description": "Vitamins and minerals are important for the body...",
"object": "biomarker-category"
},
"valueType": "numeric",
"value": "2",
"subValueType": null,
"refIntervalHigh": 4,
"refIntervalLow": 1,
"additionalRanges": null,
"canonical": {
"status": "already-standard",
"reason": null,
"value": "2",
"formattedValue": "2 pmol/L",
"unit": "pmol/L",
"subValueType": null,
"refIntervalHigh": 4,
"refIntervalLow": 1,
"additionalRanges": null,
"isAbnormal": false
}
}
]
}
Notable fields:
| Field | Type | Description |
|---|---|---|
referralUuid | string (uuid) | UUID of the referral this result was generated from. |
resultAttachment | string (url) | null | Signed URL to the result PDF. May be null if a PDF has not yet been generated. URLs are time-limited — re-fetch the result to obtain a fresh URL. |
origin | string | null | Where the originating referral came from (e.g. API). |
createdAt | string (ISO 8601) | Time the result record was created. |
deletedAt | string (ISO 8601) | null | Set if the result has been soft-deleted. |
biomarkerSummary | object | null | Aggregated counts of normal, abnormal, critical, and total biomarkers on the result. |
biomarkers[].sampleType | string | null | The sample type used for this biomarker (e.g. blood, urine-spot). |
biomarkers[].unit | string | null | The unit the laboratory reported the value in, exactly as it reported it. Laboratories can differ in unit and in spelling (U/L and IU/L for the same enzyme). For a value you can compare across laboratories, read canonical. |
biomarkers[].mostCommonUnit | string | null | The unit the major laboratories most often send for this biomarker, as we have observed it. A description of the data, not a standard; see List Biomarkers. |
biomarkers[].canonical | object | The result in the biomarker's canonical unit, so results from different laboratories can be compared. Always present, on text results too. See Canonical values. |
biomarkers[].notes | string | null | Lab-provided notes for the individual biomarker, if any. |
biomarkers[].additionalRanges | array | null | Additional reference ranges for numeric biomarkers (e.g. menstrual cycle-phase bands for female hormones, or lipid risk targets). null for string-valued biomarkers, and null (or omitted) on numeric biomarkers that have no extra ranges. See below for the entry shape and examples. |
additionalRanges entry shape:
Each entry in additionalRanges describes one alternative reference interval that applies in a specific context (cycle phase, lipid risk target, etc.). The biomarker's top-level refIntervalLow / refIntervalHigh remain the default range — additionalRanges are supplementary and consumers should continue to use the primary range for the default in/out-of-range determination.
| Field | Type | Description |
|---|---|---|
id | string | Identifier for the range (e.g. cycle phase key, "high-risk"). |
name | string | Human-readable name for the range (e.g. "Follicular phase", "Lipid risk target"). |
category | string | Category the range belongs to (e.g. "female-hormone", "lipid-risk-target"). |
lowRange | number | null | Lower bound of the range. null means open-ended on the low side (e.g. < highRange). |
highRange | number | null | Upper bound of the range. null means open-ended on the high side (e.g. > lowRange). |
Example — female sex hormone with cycle-phase ranges (e.g. Oestradiol, FSH, LH, Progesterone):
For female hormone biomarkers, the lab cannot determine the patient's cycle phase, so the primary refIntervalLow / refIntervalHigh are typically null and all phase-specific ranges are returned via additionalRanges:
{
"refIntervalLow": null,
"refIntervalHigh": null,
"additionalRanges": [
{ "id": "follicular", "name": "Follicular phase", "category": "female-hormone", "lowRange": 46, "highRange": 607 },
{ "id": "ovulation", "name": "Ovulation", "category": "female-hormone", "lowRange": 315, "highRange": 1828 },
{ "id": "luteal", "name": "Luteal phase", "category": "female-hormone", "lowRange": 161, "highRange": 774 },
{ "id": "post-menopause", "name": "Post-menopausal", "category": "female-hormone", "lowRange": null, "highRange": 201 }
]
}
Example — lipid risk targets (e.g. Total Cholesterol, LDL, HDL, Triglycerides, Non-HDL):
Some lipid panels include a separate cardiovascular risk target (e.g. NVDPA high-risk targets) alongside the standard reference range. The standard range is reported as the primary refIntervalLow / refIntervalHigh, and the risk target is exposed as a "high-risk" entry under additionalRanges:
LDL Cholesterol — the primary range is the lab's standard reference; the additional entry is the high-cardiovascular-risk target (< 2.0 mmol/L):
{
"refIntervalLow": null,
"refIntervalHigh": 3.4,
"additionalRanges": [
{ "id": "high-risk", "name": "Lipid risk target", "category": "lipid-risk-target", "lowRange": null, "highRange": 2.0 }
]
}
HDL Cholesterol — the high-risk target is a minimum (> 1.0 mmol/L), so highRange is null:
{
"refIntervalLow": 1.0,
"refIntervalHigh": null,
"additionalRanges": [
{ "id": "high-risk", "name": "Lipid risk target", "category": "lipid-risk-target", "lowRange": 1.0, "highRange": null }
]
}
Most biomarkers will return "additionalRanges": null — only those with clinically meaningful contextual ranges (female sex hormones, lipid risk targets) populate the array.
Canonical values
Laboratories don't all report a biomarker the same way. The unit can differ in
scale (mmol/L or umol/L) or only in spelling (U/L or IU/L for the same
enzyme). If you store each result exactly as reported, a patient's trend line
can mix scales without anything telling you.
Every result biomarker therefore carries a canonical object. It holds the same
result expressed in the biomarker's canonical unit, the one unit we have
agreed for that biomarker. The laboratory's own value, unit and ranges on the
biomarker are always returned unchanged. canonical is an addition, never a
replacement.
canonical has the same fields as the biomarker itself, so the code that shows
a result can show its canonical value too:
| Field | Type | Description |
|---|---|---|
status | string | What canonical holds. Always set. See the table below. |
reason | string | null | Why there is no canonical value, for not-convertible and no-standard. null otherwise. |
value | string | null | The value in the canonical unit. |
formattedValue | string | null | The value with its unit, formatted as for the biomarker itself. |
unit | string | null | The canonical unit, as listed for this biomarker by List Biomarkers (canonicalUnit). |
subValueType | string | null | Carried over from the result. A value of <0.5 in mmol/L is <500 in umol/L. |
refIntervalHigh | number | null | The upper reference interval in the canonical unit. |
refIntervalLow | number | null | The lower reference interval in the canonical unit. |
additionalRanges | array | null | Any additional ranges, in the canonical unit. |
isAbnormal | boolean | null | The laboratory's own abnormal flag, copied. Converting a value and its range by the same factor cannot change it. |
Every field except status and reason is null unless status is
already-standard, converted or lab-reported.
status
| Status | Values? | Meaning |
|---|---|---|
already-standard | Yes | The laboratory reported in the canonical unit, possibly spelt differently. Its numbers are returned as they are, and the unit is spelt the canonical way. |
converted | Yes | The laboratory reported in another unit of the same kind, and the value and ranges were converted into the canonical unit. |
lab-reported | Yes, unconverted | The laboratory is not one whose results are standardised (see below), so these are its own value, unit and ranges, copied. Never compare these with standardised values, even when the unit looks the same. |
not-convertible | No | The laboratory's unit can't safely be converted for this biomarker. reason says why. |
unknown-unit | No | We could not read the laboratory's unit. |
no-standard | No | This biomarker has no canonical unit. reason says whether one is coming. |
reason
| Reason | With | Meaning |
|---|---|---|
units-not-interchangeable | not-convertible | The two units measure different things for this biomarker (for example a mass and an amount of substance), or no approved conversion joins them. |
invalid-value | not-convertible | The value, or a range bound, is not a usable number. |
under-review | no-standard | No decision yet. Every new biomarker starts here, and a standard may or may not follow. |
awaiting-conversion | no-standard | A standard is decided, and applies once conversion between unit families is available. |
not-standardised | no-standard | Decided to have none, because laboratories use units that can't be compared. |
unavailable | either | We couldn't work it out this time. Fetch the result again later rather than treating this as permanent. |
Which laboratories are standardised
Results are standardised for the major pathology networks, with all of their laboratories: 4Cyte, ACL, Healius and Sonic. SA Pathology, SydPath and results recorded by Eirly are standardised too.
Results from any other laboratory are lab-reported. That covers specialist
providers that use their own units and assays, and results a patient uploads
themselves. A specialist laboratory's value in a familiar-looking unit can still
be on a different scale from everyone else's, which is why these are copied
under their own status instead of being called standard.
How values are converted
- All or nothing. The value, the reference interval and any additional
ranges are converted together. If any one of them can't be converted, none is,
and the status is
not-convertible. - Rounded to the laboratory's precision. Each converted number is rounded to
the significant figures it had as reported, so
0.08 mmol/Lbecomes80 umol/Lrather than80.00000000000001. Each range bound is rounded to its own figures in the same way. - Within a unit family only. Today a value converts between prefixes and
spellings of the same kind of unit (
mmol/Ltoumol/L,IU/Lread asU/L), never between kinds (mass to amount of substance). Biomarkers waiting on that areno-standardwith reasonawaiting-conversion. - Text results (
valueType: "string", such asA+orDetected) have no number to convert, so the value is copied. One with no unit, or in the canonical unit, isalready-standard. One from a laboratory that is not standardised islab-reported. A text result in any other unit, such as<2in a unit that isn't the standard, gets the status a number in that unit would, with no values.
Examples
An illustrative creatinine result reported in mmol/L, converted to the
canonical umol/L:
{
"id": "creatinine",
"valueType": "numeric",
"value": "0.08",
"formattedValue": "0.08 mmol/L",
"unit": "mmol/L",
"mostCommonUnit": "umol/L",
"refIntervalLow": 0.06,
"refIntervalHigh": 0.11,
"isAbnormal": false,
"canonical": {
"status": "converted",
"reason": null,
"value": "80",
"formattedValue": "80 umol/L",
"unit": "umol/L",
"subValueType": null,
"refIntervalLow": 60,
"refIntervalHigh": 110,
"additionalRanges": null,
"isAbnormal": false
}
}
A biomarker with no standard yet. The laboratory's own value is still on the biomarker:
"canonical": {
"status": "no-standard",
"reason": "under-review",
"value": null,
"formattedValue": null,
"unit": null,
"subValueType": null,
"refIntervalLow": null,
"refIntervalHigh": null,
"additionalRanges": null,
"isAbnormal": null
}
Using it
- To compare or chart results across laboratories, use
canonical.valuewherestatusisalready-standardorconverted. Those two are in the same unit for a given biomarker, whichever laboratory sent them. - Keep
lab-reportedvalues apart. Show them with their own unit, and don't put them on the same chart as standardised values. - For everything else, fall back to the laboratory's value. A result without
a canonical value is still a valid result. Show
valueandunitas reported. - Branch on
status, not on whethervalueis null. New reasons may be added, so treat anyreasonyou don't recognise as "no canonical value". - A standard can arrive later. When a biomarker that was
no-standardgets a canonical unit, its results start coming backalready-standardorconverted. Results are re-derived on every request, so re-fetching an old result picks this up. Once a biomarker has a canonical unit, that unit doesn't change.
List Results
GET /v1/results
Returns all results for your organisation with pagination support.
Query Parameters:
count(integer, optional) - Number of results to return (max 20, default 20)cursor(string, optional) - Cursor for paginationsort(string, optional) - Sort order:ascordesc(default:asc)
Response (200 OK): Array of results with cursor metadata
Tests
Tests represent the available tests, their pricing, and the biomarkers each one is expected to report.
List Tests
GET /v1/tests
Returns all available tests for your organisation.
Query Parameters:
include(string, optional) - Comma-separated list of optional sections to add to each test. The only supported value today isbiomarkers. An unrecognised value returns400 INVALID_PARAMETER.
By default the response does not carry biomarkers, and the key is absent
rather than an empty array. [] would mean "this test reports no biomarkers",
which is a different fact from "you did not ask for them".
This matters for size: the full catalogue is 374 KB by default and 893 KB with
?include=biomarkers, so the biomarkers are 58% of the larger response. Ask for
them when you need them, and cache the result rather than fetching them on every
call.
GET /v1/tests?include=biomarkers
Response (200 OK):
The example below is the ?include=biomarkers response. Without the parameter it
is identical except that the biomarkers key is absent; no other field changes.
[
{
"id": "cortisol",
"name": "Cortisol",
"shortName": "Cortisol",
"testType": "blood",
"price": 32.03,
"priceCents": 3203,
"medicareListed": true,
"object": "test",
"biomarkers": [
{
"id": "cortisol",
"name": "Cortisol",
"commonName": "Cortisol",
"object": "biomarker",
"valueType": "numeric",
"sampleType": "blood",
"unit": "nmol/L",
"canonicalUnit": "nmol/L",
"mostCommonUnit": "nmol/L",
"standardStatus": "standardised",
"description": "A steroid hormone released by the adrenal glands.",
"category": {
"id": "hormone",
"name": "Hormones",
"object": "biomarker-category",
"description": "Hormones and their metabolites"
}
}
],
"turnAroundTimes": [
{
"maximumDays": null,
"minimumDays": null,
"providerId": "4cyte",
"providerName": "4Cyte Pathology",
"object": "providerTurnaroundTime"
}
],
"turnAroundTimeAverage": {
"maximumDays": 7,
"minimumDays": 7,
"object": "turnaroundTime"
}
}
]
Fields:
| Field | Type | Description |
|---|---|---|
price | number | The price of the test to your organisation, in dollars. Kept for backwards compatibility. |
priceCents | number | The same price in cents, as a whole number. Prefer this for any arithmetic, and for consistency with POST /v1/calculate, which returns cents. |
testType | string | The kind of sample the test uses, e.g. blood. |
medicareListed | boolean | Whether this test can be ordered under Medicare through us, i.e. whether it may appear in medicareTests on an order. See the caution below: this is not a statement that the test is Medicare-eligible for a given patient. |
biomarkers | array | Only present with ?include=biomarkers. The biomarkers this test is expected to report, each with the same shape as an item from List Biomarkers. Empty array if the test has none. |
turnAroundTimes | array | null | Per-provider turnaround times. A null on minimumDays / maximumDays means we have no published figure for that provider, not that it is instant. |
turnAroundTimeAverage | object | null | The average turnaround across the providers that publish one. |
medicareListed is our answer, not the lab'smedicareListed says whether we will accept the test in the medicareTests
array on an order. It is not a statement that the test is Medicare-eligible for
a particular patient, and it is not a promise that the test will be bulk billed.
Eligibility and bulk billing are determined by the collecting laboratory and the
patient's Medicare eligibility at the time of collection, and the patient may
still be charged a gap or the full fee. A test with "medicareListed": false
will be rejected with INVALID_MEDICARE_TEST if you send it in medicareTests.
price is in dollars, not centsprice is the only money field on this API expressed in dollars. It is divided
by 100 before it is returned, so it can carry a fractional part (32.03).
Every other money value, including everything returned by
POST /v1/calculate, is a whole number of cents. Use priceCents to avoid the
mismatch.
Notes:
biomarkerslists what the test is configured to report. It is the expected set, not a guarantee that the laboratory returns a value for every one of them on a given result.- Biomarkers are largely a pathology concept: they are populated for blood tests, and most collection-kit products (stool, saliva, urine, bloodspot, allergy) return an empty array.
- The API version does not change this response. Both
v1.0.0andv1.1.0return the full catalogue, every test type included. The version matters when you order:bundleIdandmedicareTestson Create Referral requirev1.1.0, andv1.1.0returns an array of referrals wherev1.0.0returns a single one. So a test appearing here does not by itself mean your version can order it.
Biomarkers
Biomarkers are the individual measurements a test reports. This endpoint is the
full catalogue, independent of any patient or result. Use it to build a local
lookup of units, categories and descriptions that you can join result biomarkers
against by id.
List Biomarkers
GET /v1/biomarkers
Returns every biomarker in the catalogue.
Response (200 OK):
[
{
"id": "cortisol",
"name": "Cortisol",
"commonName": "Cortisol",
"object": "biomarker",
"valueType": "numeric",
"sampleType": "blood",
"unit": "nmol/L",
"canonicalUnit": "nmol/L",
"mostCommonUnit": "nmol/L",
"standardStatus": "standardised",
"description": "A steroid hormone released by the adrenal glands.",
"category": {
"id": "hormone",
"name": "Hormones",
"object": "biomarker-category",
"description": "Hormones and their metabolites"
}
}
]
Fields:
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier. Matches biomarkers[].id on a result and on a test. |
name | string | The formal name of the biomarker. |
commonName | string | null | A more familiar name for the same biomarker, where one exists. |
unit | string | null | The unit the biomarker is reported in. null for biomarkers that have no unit. |
canonicalUnit | string | null | The agreed unit this biomarker's results are standardised to: the unit of canonical on every result. null until one is decided. |
mostCommonUnit | string | null | The unit the standardised laboratories most often send for this biomarker, as we have observed it. null where there is too little data. |
standardStatus | string | Whether this biomarker has a canonical unit, or why not: standardised, under-review, awaiting-conversion or not-standardised. |
valueType | string | numeric or string. A string biomarker reports a text value and has no reference interval. |
sampleType | string | null | The sample the biomarker is measured from, e.g. serum, urine. |
category | object | null | The grouping the biomarker belongs to. null for uncategorised biomarkers. |
Notes:
- The same shape appears nested under each test returned by List Tests, so one biomarker parser serves both endpoints.
canonicalUnitis a decision;mostCommonUnitis an observation. They usually agree, but they don't have to.mostCommonUnitcan be set where there is nocanonicalUnityet, can differ from it, and can change when we survey the data again. Don't treat it as a standard. WherecanonicalUnitisnull, it is the best guide to the unit you will see.standardStatusmatches thereasona result gets when there is no standard:standardised(canonicalUnitis set and won't change),under-review(no decision yet, as for every new biomarker),awaiting-conversion(decided, and arrives once conversion between unit families is available) ornot-standardised(decided to have none). A biomarker can move fromunder-revieworawaiting-conversiontostandardised.- This is catalogue data with no patient value attached. Reference intervals are
returned per-result, on
biomarkers[]of a result, because they depend on the reporting laboratory.
Bundles
Bundles are pre-configured groups of tests that can be purchased together (v1.1.0+ only).
List Bundles
GET /v1/bundles
Headers Required:
Accept: application/vnd.bgt.v1.1.0+json
Query Parameters:
count(integer, optional) - Number of results to return (max 20, default 20)cursor(string, optional) - Cursor for paginationsort(string, optional) - Sort order:ascordesc(default:asc)
Response (200 OK):
{
"cursor": {
"cursor": "MjAyNC0wNi0wNFQxMjo1OTowMS44MDJafGNyZWF0ZWRBdHxhc2M=",
"count": 20,
"resultCount": 5
},
"bundles": [
{
"id": "254fa374-6d05-4a41-bfd8-254ba2039049",
"name": "Comprehensive Health Panel",
"object": "bundle",
"tests": [
{
"id": "tft",
"name": "Thyroid Function Test",
"object": "test"
}
],
"createdAt": "2024-06-04T05:29:06.508Z"
}
]
}
Providers
A provider record is a doctor's Medicare provider number, the details printed on their referrals, and their signature, held against your organisation. It is what lets you order Medicare tests on that doctor's behalf, and the practitioner never needs an account with us.
Setting one up has two halves, and they are independent:
- Ordering. Create the record with a provider number and signature, and that practitioner can order Medicare tests immediately.
- Results coming back. A pathology lab returns a result carrying only the ordering provider number. It will route that result to us only once it has confirmed with us that the number belongs to your practice. Until then, results for that practitioner go wherever they go today.
Creating the record does not start the second half. Call Request Result Routing for that — it queues the confirmation request with us, and our team then approaches the labs.
The API says vendor where this page says lab: GET /v1/vendors, vendorIds,
vendorId. They are the same thing. The API uses the broader word because the
list can also include a message-routing service rather than a laboratory, and
you request routing from both the same way. Read vendor as "somewhere results
come from".
A connection is one lab's routing state for one provider number. A doctor
with two labs has two connections, which is what connections[] holds on every
provider payload.
| You want to | Call |
|---|---|
| Let a doctor order Medicare tests | Create Provider |
| Learn which labs you can ask | List Labs |
| Make that doctor's results come back | Request Result Routing |
| Check whether a lab has confirmed | Get Provider |
| See everything you hold | List Providers |
| Correct details, or replace a signature | Update Provider |
| Stop a doctor ordering | Retire Provider |
For the end-to-end sequence, including what happens between your routing request and a lab confirming, see Medicare provider setup.
Create Provider
POST /v1/providers
Registers a doctor against your organisation. If the provider number is already held by a live record in your own organisation, that record is updated in place rather than rejected, so re-sending a doctor is safe and idempotent.
Request Body:
providerNumber(string, required) - Medicare provider number: a 5 or 6 digit stem, a practice location character and a check character, so 7 or 8 characters in total (a leading zero on the stem may be dropped). Validated with Medicare's checksum, which confirms it is well formed, not that it is registeredname(string, required) - The doctor's name as it should appear on referralsaddress(string, required) - The practice address as it should appear on referralsencodedSignature(string, required) - Base64 PNG, with or without adata:image/png;base64,prefix. PNG only — the signature is stamped onto the referral as an image, so other formats are rejected rather than convertedtitle(string, optional) - Honorific (Dr, A/Prof, Prof)phone(string, optional) - Contact phone for this doctor at this practiceemail(string, optional) - Contact email for this doctor at this practicefirstName/lastName(string, optional) - Used only to label the record; derived fromnamewhen omitted
Response (200 OK):
{
"object": "provider",
"providerNumber": "2426621B",
"name": "Jane Vein",
"address": "1 Practice Street, Brisbane QLD 4000",
"title": "Dr",
"phone": "0412345678",
"email": "jane.vein@example.com",
"hasSignature": true,
"signatureSignedAt": "2026-09-15T02:11:00.000Z",
"encodedSignature": "data:image/png;base64,iVBORw0KGgo...",
"createdAt": "2026-09-15T02:10:59.000Z",
"updatedAt": "2026-09-15T02:11:00.000Z",
"connections": []
}
connections is empty until you request routing.
A number held by a different organisation returns 409 PROVIDER_NUMBER_IN_USE.
A provider number belongs to one practice at a time, because it is the only thing
identifying who ordered a result. We do not say which organisation holds it.
List Labs
GET /v1/vendors
The labs you can request result routing from. The id of each is what you pass
in vendorIds on Request Result Routing.
Only labs we can currently reach are listed, so anything returned here is safe to request. Read this rather than hardcoding ids — the list changes as labs are added or retired, and a hardcoded id fails the moment one does.
Response (200 OK):
{
"vendors": [
{
"object": "vendor",
"id": "4cyte",
"name": "4Cyte Pathology",
"route": "via Medical Objects"
},
{
"object": "vendor",
"id": "healius",
"name": "Healius",
"route": "direct to Healius"
}
]
}
route describes how results reach us from that lab, and is informational only:
it does not change how you call the API. Some labs send to us through Medical
Objects, a clinical messaging service widely used in Australian pathology, and
others send to us directly.
Request Result Routing
POST /v1/providers/{providerNumber}/routing
The practitioner is identified by the provider number in the path; the body
carries only the labs and your attestation. Get the vendorIds from
List Labs.
What this call does: it records your attestation and creates one pending request per lab you named. That is all. It sends no email and makes no call to the lab. Nothing leaves our system when you call it.
What happens next: those requests sit at NOT_SUBMITTED until our team
notifies the labs, which we do in batches because the labs are non-technical and
one email per doctor gets lost. The status becomes PENDING_CONFIRMATION when a
batch goes out, and CONNECTED when the lab writes back confirming — usually
days to weeks later. Poll Get Provider and read
connections[].status to follow it.
Why you have to call it: a lab returns a result carrying only the ordering
provider number, and routes it to us only once it has confirmed with us that the
number belongs to your practice. CONNECTED is that confirmation. Until a lab
reaches it, results for this practitioner go wherever they go today, no matter
how many orders you place.
It is a separate call rather than part of creating the record because sending a doctor's name, provider number, address and signature to a third party is a disclosure. We require an explicit act with an attestation attached rather than doing it as a side effect of creating a record.
Preconditions: the provider record exists and has a signature on file. The
signature is the authority the lab is shown, so a record without one is rejected
with MISSING_SIGNATURE.
Request Body:
vendorIds(string[], required) - The labs to ask, from List Labs. Each must be a currently enabled labattestation.attestedBy(string, required) - Who authorised this disclosure on the doctor's behalf (a name, email or staff id)attestation.doctorSignedAt(string, required) - When the doctor signed the authority you hold, ISO 8601attestation.reference(string, optional) - Your own reference for that authority
Response (200 OK):
{
"connections": [
{
"object": "providerConnection",
"vendorId": "4cyte",
"vendorName": "4Cyte Pathology",
"status": "NOT_SUBMITTED",
"consentedAt": "2026-09-15T02:12:00.000Z",
"confirmedAt": null,
"actionNeededReason": null
}
]
}
Routing statuses:
| Status | Meaning |
|---|---|
NOT_SUBMITTED | Authorised, waiting for our team's next batch to the lab |
PENDING_CONFIRMATION | The lab has been asked and has not replied yet. Usually days to weeks |
CONNECTED | The lab confirmed. Results for this provider number now route to you |
ACTION_NEEDED | The lab could not proceed. See actionNeededReason |
STOP_REQUESTED | Routing is being wound down at that lab |
consentedAt is when you made the routing request, not when the doctor signed
your paperwork. confirmedAt is when the lab confirmed, and is null until then.
Get Provider
GET /v1/providers/{providerNumber}
Returns one provider record with its signature and the current routing status at each lab. This is the endpoint to poll while waiting for labs to confirm.
Response (200 OK): the same shape as Create Provider, with
connections populated.
List Providers
GET /v1/providers
Every provider record your organisation holds, newest first, whether it was created through this API or by a practitioner in the portal.
Query Parameters:
count(integer, optional) - Number of results to return (max 20, default 20)cursor(string, optional) - Cursor from a previous pagesort(string, optional) - Sort order:ascordesc(default:desc)
Response (200 OK):
{
"providers": [
{
"object": "provider",
"providerNumber": "2426621B",
"name": "Jane Vein",
"address": "1 Practice Street, Brisbane QLD 4000",
"title": "Dr",
"phone": "0412345678",
"email": "jane.vein@example.com",
"hasSignature": true,
"signatureSignedAt": "2026-09-15T02:11:00.000Z",
"encodedSignature": "data:image/png;base64,iVBORw0KGgo...",
"createdAt": "2026-09-15T02:10:59.000Z",
"updatedAt": "2026-09-15T02:11:00.000Z",
"connections": []
}
],
"cursor": {
"cursor": "MjAyNi0wOS0xNVQwMjoxMDo1OS4wMDBafGNyZWF0ZWRBdHxkZXNj",
"count": 20,
"resultCount": 1
}
}
Every provider payload has the same shape, whichever endpoint returned it.
A cursor that did not come from this endpoint is rejected with
INVALID_PARAMETER rather than silently returning the first page.
Update Provider
PUT /v1/providers/{providerNumber}
Updates the details printed on this doctor's referrals, and optionally replaces
the signature on file. Omit encodedSignature to leave the existing one alone.
The provider number itself cannot be changed: it is the record's identity, and any lab routing already confirmed is bound to it. Retire the record and create the new number instead.
Request Body: any of name, address, title, phone, email,
encodedSignature.
Response (200 OK): the full provider record.
Retire Provider
DELETE /v1/providers/{providerNumber}
Retires the record and clears the signature, so the number can no longer order.
Refused with 409 PROVIDER_HAS_ACTIVE_ROUTING while any lab is still routing
results for it. The lab has to be told to stop first, or it keeps sending us
results for a doctor you no longer represent. Contact support to wind routing
down.
Response (200 OK):
{ "object": "provider", "providerNumber": "2426621B", "deleted": true }
Collection Centres
A collection centre is somewhere a patient can walk in to have a sample taken: every collection centre of the labs we work with, about 5,900 across Australia. Use them to show a patient where they can go once you have ordered a referral, on a map or as a list.
Each centre belongs to a pathology provider, the brand on the door
(dorevitch, laverty), and each provider belongs to a vendor, its parent
company (healius). We refresh every lab's listing weekly.
| You want to | Call |
|---|---|
| Find the centres nearest a patient | GET /v1/collection-centres?lat=…&lng=…&count=5 |
| Load a map showing what is on screen | GET /v1/collection-centres?north=…&south=…&east=…&west=…&count=10000 |
| Load every centre at once, to cache | GET /v1/collection-centres?count=10000 |
| Search as someone types a suburb, street or clinic | GET /v1/collection-centres?search=… |
| Only show the labs a referral is for | add pathologyProviders=… or vendors=… to any of the above |
| Fetch the centres a webhook named | GET /v1/collection-centres?uuids=…, or one by uuid |
List Collection Centres
GET /v1/collection-centres
Query Parameters: every parameter is optional. List parameters take
comma-separated values (states=VIC,NSW); repeating the key
(states=VIC&states=NSW) works too.
| Parameter | Type | Matches | When you'd use it |
|---|---|---|---|
uuids | list | Only these centres, up to 100, whatever their state: RETIRED centres and ones with unmappable coordinates, which every other query leaves out, are returned. | Fetching the centres a collection_centres.updated delivery named. |
pathologyProviders | list | The provider, by id (see Provider ids). | The patient's referral is for Dorevitch, so only Dorevitch centres will accept it: pathologyProviders=dorevitch. |
vendors | list | The parent company: 4cyte, acl, healius, sonic. | You deal with a group, not one brand: vendors=sonic shows all ten Sonic labs. |
testingTypes | list | PATHOLOGY, IMAGING. | Keeping a pathology map free of imaging sites. Every centre today is PATHOLOGY; IMAGING is accepted and matches none until imaging sites are listed. |
states | list | State abbreviation: NSW, VIC, QLD, SA, WA, TAS, NT, ACT. | A state-level list, or keeping a nationwide search to the states you operate in. |
postcodes | list | Four-digit postcode, exactly (0810, not 810). | A patient gave you a postcode rather than a location: postcodes=3000,3002. |
search | text | Part of the name, venue name, street address or suburb, ignoring case. At most 100 characters. | A search box. search=Keilor finds Keilor, Keilor Downs, Keilor East and centres on Keilor Road. |
changedSince | text | Only centres created, changed, gone missing, come back or retired at or after this time (ISO 8601). Also returns centres RETIRED since then, which no other query returns. | Keeping your own copy in sync: pass data.ranAt from the collection_centres.updated webhook, or the time of your last sync. |
lat, lng | number | Sorts nearest first and adds distanceKm. Both are required together. | You know where the patient is: from their address, or the browser's location. |
radiusKm | number | Only centres within this many kilometres of lat/lng. At most 5000. Needs lat and lng. | "Anything within 10 km of home": you would rather show nothing than something too far to be useful. |
north, south, east, west | number | Everything inside the box, edges included. All four are required together. | A map: pass the viewport's edges on every pan and zoom. north is the top edge, west the left. |
count | number | Centres per page. Default 50; a larger value than 10000 is clamped to 10000. Below 1 or not a whole number is 400 INVALID_PARAMETER. | count=5 for "the five nearest"; count=10000 for everything in one call. |
cursor | text | The cursor.cursor from the previous page. | Fetching the next page. Send the same filters with it. |
An unknown parameter or value is rejected with 400 INVALID_PARAMETER, never
ignored. A typo such as pathologyProvider=dorevitch (singular) or
states=Victoria would otherwise drop the filter and return every centre in the
country as if it were the answer. Every problem in the request is listed in
errorList, so you can fix them in one go:
{
"error": true,
"code": "INVALID_PARAMETER",
"message": "The parameter provided is invalid",
"errorList": [
"Unsupported parameter: pathologyProvider. Supported parameters: pathologyProviders, vendors, testingTypes, states, postcodes, search, lat, lng, radiusKm, north, south, east, west, count, cursor",
"A search around a point needs all of lat, lng"
]
}
How the filters combine
- Different filters narrow each other (AND). A centre must match every filter
you pass.
vendors=healius&states=VICis Healius centres in Victoria. - Values within one filter widen it (OR). A centre may match any one of them.
states=VIC,NSWis centres in Victoria or New South Wales. - A filter you leave out does not constrain. With no parameters at all you get every centre, 50 at a time, by name.
- Provider and vendor together is allowed, but only useful when they agree:
vendors=healius&pathologyProviders=dorevitchis the same aspathologyProviders=dorevitchalone, andvendors=sonic&pathologyProviders=dorevitchis empty, because Dorevitch is a Healius lab. searchis one more filter.search=collins&states=VICis centres in Victoria whose name, venue, address or suburb contains "collins".- Order depends on whether you gave a point. With
latandlng, results are nearest first. Without them, they are by name. No other parameter changes the order.
Keeping a copy in sync
If you store the centres yourself, subscribe to
collection_centres.updated. After
each weekly refresh that changes anything, it tells you which centres changed.
Then fetch just those:
GET /v1/collection-centres?changedSince=2026-09-27T16:00:00.000Z&count=10000
changedSince returns every centre created, changed, gone missing, come back
or retired since that time. It is the only query that returns retired centres
(status: "RETIRED"), so you learn to remove them. It combines with every other
filter, and a missed delivery costs nothing: query from the time of your last
sync instead.
Get Collection Centre
GET /v1/collection-centres/{uuid}
One centre by its uuid, in the same shape as a list entry, whatever its
state. A centre that is MISSING, RETIRED, or has coordinates too wrong to
put on a map is still returned here, although listings leave the last two out.
So every uuid a collection_centres.updated
delivery names can be fetched.
400 INVALID_PARAMETERif{uuid}is not a uuid.404 COLLECTION_CENTRE_NOT_FOUNDif it has never been a centre.
To fetch many at once, use ?uuids= on List Collection Centres,
up to 100 per request.
Around a point: with or without a radius
lat and lng on their own return every centre, nearest first, and count
decides how many you see. This is how to find "the closest five" when you cannot
know how far away they are:
GET /v1/collection-centres?lat=-25.898&lng=139.352&count=5
That is Birdsville, where the nearest centre is 565 km away in Longreach, and the next two are across the border in South Australia. A 500 km radius would have returned nothing.
Add radiusKm when a distant centre is no use to you. The radius is honoured
exactly and never widened, so an empty result means there is nothing within
it:
GET /v1/collection-centres?lat=-37.8136&lng=144.9631&radiusKm=2
Without a radius, totalResults is every centre that matches your other
filters, because nothing has been excluded, only sorted. Use count to take what
you need rather than paging through the rest.
near is by straight-line distance, so it crosses state borders freely. Add
states if you need to stay inside one.
A map viewport
Pass the four edges of what is on screen, and a count high enough for the
busiest view:
GET /v1/collection-centres?north=-37.70&south=-37.90&east=145.10&west=144.80&count=10000
Swapped edges (a south greater than north) are rejected rather than returning
an empty map. A box crossing the antimeridian is not supported; nothing in
Australia needs one.
A point and a box can be combined: centres inside the box, nearest the point first, which suits a map that also highlights the centres closest to the patient.
Everything at once
count goes up to 10,000, above the total number of centres, so a single call
returns them all. A larger count is clamped to 10,000 rather than refused, and
cursor.count tells you the size applied:
GET /v1/collection-centres?count=10000
Every centre with every field is several megabytes. If you are loading them to draw a map, cache the response: the data changes weekly, not by the minute.
Paging
Paging works exactly as on every other list endpoint (see
Pagination): every page with results carries a cursor, the last
one included. Pass its cursor value back as the cursor parameter for the next
page, and stop when cursor.resultCount is less than cursor.count. Compare
with cursor.count from the response, not the count you sent, because a
larger request is clamped.
Send the same filters with the cursor. A cursor is a position in one result
set, so changing the filters between pages gives you a page of a different list.
totalResults is the count across every page, so you can show "1 to 20 of 49"
before fetching the rest.
Provider ids
Vendor (vendors) | Pathology providers (pathologyProviders) |
|---|---|
4cyte | 4cyte |
acl | acl |
healius | abbott, dorevitch, laverty, qml, tml, wdp |
sonic | capital, clinipath (WA), clinpath (SA), douglass-hanly-moir, hobart, launceston, melbourne-pathology, north-west-pathology, snp, southern-iml |
Every pathologyProviderId in a response is hyphenated. For multi-word ids the
underscore spelling some of our other data uses (douglass_hanly_moir) is
accepted as a filter too, and matches the same centres.
Opening hours
openingHours is the centre's regular week: always seven days, Monday first,
whatever order the lab lists them in.
"openingHours": [
{ "day": "MONDAY", "status": "OPEN", "periods": [{ "opens": "07:30", "closes": "12:30" }, { "opens": "13:00", "closes": "15:30" }], "text": "7:30AM to 12:30PM, 1PM to 3:30PM" },
{ "day": "SATURDAY", "status": "CLOSED", "periods": [], "text": "Closed" },
{ "day": "SUNDAY", "status": "UNKNOWN", "periods": [], "text": null }
]
- Times are local to the centre, in 24-hour
HH:MM. A period that ends at midnight closes at24:00, soclosesis always later thanopens. - A day with a break has more than one period.
UNKNOWNmeans we will not guess. The lab's listing for that day is not plain enough to read (a typo such as8:30 AM to 1:30 AM, or a note true only on the day we read it), or it left the day out.textcarries what the lab wrote, so a person can still read it.openingHoursisnullwhen a lab publishes no hours at all, which today is every Sonic lab. Treat that as "unknown", not "closed".- Public holidays and one-off closures are not in
openingHours; the lab's notices are innotes.
Response (200 OK):
{
"collectionCentres": [
{
"object": "collectionCentre",
"uuid": "cbbe9ea7-2a14-4a67-ab82-c3998869a5be",
"pathologyProviderId": "dorevitch",
"pathologyProviderName": "Dorevitch Pathology",
"vendorId": "healius",
"vendorName": "Healius",
"name": "Melbourne CBD",
"venueName": null,
"address": "Collins Arcade Level 1,, 260 Collins Street",
"city": "Melbourne",
"state": "VIC",
"postcode": "3000",
"country": "AU",
"lat": -37.8152725,
"lng": 144.9650986,
"phone": "03 9099 0008",
"notes": null,
"services": ["AHPRA", "Breath Test (Urea) - C14"],
"bookingUrl": null,
"openingHours": [
{ "day": "MONDAY", "status": "OPEN", "periods": [{ "opens": "08:00", "closes": "16:00" }], "text": "8AM to 4PM" },
{ "day": "TUESDAY", "status": "OPEN", "periods": [{ "opens": "08:00", "closes": "16:00" }], "text": "8AM to 4PM" },
{ "day": "WEDNESDAY", "status": "OPEN", "periods": [{ "opens": "08:00", "closes": "16:00" }], "text": "8AM to 4PM" },
{ "day": "THURSDAY", "status": "OPEN", "periods": [{ "opens": "08:00", "closes": "16:00" }], "text": "8AM to 4PM" },
{ "day": "FRIDAY", "status": "OPEN", "periods": [{ "opens": "08:00", "closes": "16:00" }], "text": "8AM to 4PM" },
{ "day": "SATURDAY", "status": "CLOSED", "periods": [], "text": "Closed" },
{ "day": "SUNDAY", "status": "CLOSED", "periods": [], "text": "Closed" }
],
"testingTypes": ["PATHOLOGY"],
"status": "ACTIVE",
"distanceKm": 0.26,
"lastSeenAt": "2026-09-24T13:04:49.353Z"
}
],
"totalResults": 8,
"cursor": {
"cursor": "MnxwYWdlfGFzYw==",
"count": 1,
"resultCount": 1
}
}
That is the nearest of 8 Healius centres within 2 km of Melbourne CBD
(?lat=-37.8136&lng=144.9631&radiusKm=2&vendors=healius&count=1).
The fields worth knowing:
nameis the lab's own. Some labs use the street address as the name, and several centres in one town can share a name (seven Dorevitch centres are called "Wodonga"), so showaddressalongside it.venueNameis the building or clinic the centre sits in, where the lab gives one.postcodeis a string, so NT's leading zero survives. Where a lab gives no postcode we look it up from the centre's coordinates, so a few may differ from the Australia Post postcode.servicesis what the site offers, in the lab's own words. Each lab words them differently (Holter Monitor,holter), so treat them as display text, not as values to filter on.statusisACTIVE, orMISSINGwhen the lab's latest listing left the centre out. That is usually a hiccup on their side; a centre missing for three weeks running is retired and no longer returned, except asRETIREDto achangedSincequery.distanceKmis only set when you searched around a point, and is straight-line distance, not driving distance.
Webhooks
A webhook sends events to a URL you control whenever something you subscribe to happens: a referral is issued, a result is complete, and so on. The events and the payloads your handler receives are described in Webhooks. These endpoints let you manage the subscriptions themselves over the API, instead of in the portal.
Webhooks set up here and in the portal are the same webhooks: one created over the API appears in Settings > Api Keys, and one created in the portal is listed here.
| You want to | Call |
|---|---|
| Start receiving events | Create Webhook |
| Learn the event names you can subscribe to | List Webhook Events |
| See one webhook | Get Webhook |
| See everything you have set up | List Webhooks |
| Change the URL or events, or pause deliveries | Update Webhook |
| Stop receiving events | Delete Webhook |
Every delivery is signed with your organisation's signing secret in the
x-bgt-hmac-sha256 header (see Security). The secret
is never returned by any endpoint. Copy it from Settings > Api Keys in the
portal. You do not need to open the portal before creating a webhook: the secret
exists from the moment the API is enabled for your organisation.
Rules that apply to every create and update:
- The URL must use
httpsand must not point at a private or internal address (loopback, private networks, link-local). A host that does not resolve yet, for example one you registered minutes ago or a tunnel that is still starting, is accepted; if it never comes up, deliveries to it fail. - An event can be sent to a given URL by only one of your webhooks. A second
webhook subscribing the same event to the same URL is refused with
409 DUPLICATE_WEBHOOK_SUBSCRIPTION, because it would deliver every event twice. Different events on the same URL, or the same events on different URLs, are fine. - The webhook's
idis assigned by us. A request body that sends its ownid, or any field not listed below, is refused withINVALID_PAYLOAD. - URLs are stored normalised.
https://Example.comis stored and returned ashttps://example.com/, so compare against theurlwe return rather than the string you sent.
Create Webhook
POST /v1/webhooks
Request Body:
{
"url": "https://partner.example.com/hooks/bgt",
"events": ["referral.created", "result.complete"],
"active": true
}
url(string, required) - AnhttpsURL on a public hostevents(string array, required) - One or more names from List Webhook Eventsactive(boolean, optional) - Defaults totrue. An inactive webhook keeps its settings but receives nothing
Response (200 OK):
{
"object": "webhook",
"id": "8c0dc7f0-d29b-4cd3-a05f-6cd993726bc8",
"url": "https://partner.example.com/hooks/bgt",
"events": ["referral.created", "result.complete"],
"active": true,
"createdAt": "2026-09-29T23:39:00.759Z",
"updatedAt": "2026-09-29T23:39:00.830Z"
}
Every webhook payload has this shape, whichever endpoint returned it.
Errors: 400 INVALID_WEBHOOK_URL (with the reason, for example
it must use https.), 400 INVALID_WEBHOOK_EVENTS (naming any unknown event),
400 INVALID_PAYLOAD, 409 DUPLICATE_WEBHOOK_SUBSCRIPTION.
List Webhook Events
GET /v1/webhook-events
The event names you can pass in events. The list grows as events are added, so
read it rather than hardcoding names.
Response (200 OK):
{
"events": [
"referral.created",
"referral.issued",
"referral.complete",
"result.created",
"result.complete"
]
}
Get Webhook
GET /v1/webhooks/{id}
Returns one of your webhooks.
Response (200 OK): the same shape as Create Webhook.
A webhook that does not exist, or that belongs to another organisation, returns
404 WEBHOOK_NOT_FOUND. A malformed id returns 400 INVALID_PARAMETER.
List Webhooks
GET /v1/webhooks
Every webhook your organisation holds, newest first, whether it was created through this API or in the portal.
Query Parameters:
count(integer, optional) - Number of results to return (max 20, default 20)cursor(string, optional) - Cursor from a previous pagesort(string, optional) - Sort order:ascordesc(default:desc)
Response (200 OK):
{
"webhooks": [
{
"object": "webhook",
"id": "8c0dc7f0-d29b-4cd3-a05f-6cd993726bc8",
"url": "https://partner.example.com/hooks/bgt",
"events": ["referral.created", "result.complete"],
"active": true,
"createdAt": "2026-09-29T23:39:00.759Z",
"updatedAt": "2026-09-29T23:39:00.830Z"
}
],
"cursor": {
"cursor": "MjAyNi0wOS0yOVQyMzozOTowMC43NTlafGNyZWF0ZWRBdHxkZXNj",
"count": 20,
"resultCount": 1
}
}
Paging works as on every other list endpoint; see Pagination.
Update Webhook
PATCH /v1/webhooks/{id}
Changes a webhook's URL, events or active flag. Fields you leave out keep their current value, and at least one is required.
events replaces the whole list. Send every event the webhook should
receive, not just the ones to add: sending ["result.complete"] to a webhook
subscribed to three events leaves it subscribed to one.
Request Body: any of url, events, active. To pause deliveries without
losing the webhook's settings:
{ "active": false }
Response (200 OK): the full webhook.
The same rules as Create Webhook apply, and a webhook you do
not hold returns 404 WEBHOOK_NOT_FOUND.
Delete Webhook
DELETE /v1/webhooks/{id}
Stops the webhook receiving new events. Deliveries already queued for it are still sent.
Response (200 OK):
{
"object": "webhook",
"id": "8c0dc7f0-d29b-4cd3-a05f-6cd993726bc8",
"deleted": true
}
Common Response Codes
| Code | Description |
|---|---|
| 200 | Success |
| 400 | Bad Request - Invalid parameters or payload |
| 401 | Unauthorized - Missing or invalid access token |
| 404 | Not Found - Resource does not exist |
| 500 | Internal Server Error |
Error Response Format
All error responses follow this structure:
{
"error": true,
"code": "ERROR_CODE",
"message": "Human readable error message",
"errorList": ["Additional error details"]
}
Common Error Codes
| Code | Message |
|---|---|
| MISSING_AUTHORIZATION_HEADER | Missing Authorization header |
| INVALID_JWT | Invalid access token |
| INVALID_JWT_SIGNATURE | Invalid JWT Signature |
| INVALID_PARAMETER | The parameter provided is invalid |
| INVALID_PAYLOAD | The payload provided is invalid |
| REFERRAL_NOT_FOUND | Unable to find the specified referral |
| RESULT_NOT_FOUND | Unable to find the specified result |
| COLLECTION_CENTRE_NOT_FOUND | Unable to find the specified collection centre |
| MISSING_PROVIDER_NUMBER | An ordering practitioner is required for Medicare tests. Returned when the missing practitioner is the only problem with the payload; alongside other validation failures you get INVALID_PAYLOAD with every issue listed in errorList |
| INVALID_PROVIDER_NUMBER | Provider number is not registered for your organisation (or is malformed) |
| MISSING_PROVIDER_SIGNATURE | No Medicare signature on file for the provided provider number |
| INVALID_BUNDLE_ID | The bundleId provided does not match a bundle for your organisation |
| INVALID_STATUS | The referral cannot be updated in its current status — only CREATED and ISSUED referrals can be changed |
| INVALID_MEDICARE_TEST | Only pathology (blood) tests can be ordered under Medicare |
| INVALID_MEDICARE_NUMBER | The patient's Medicare card number is missing, fails the checksum, or is missing the IRN on a Medicare order. Returned when the card number is the only problem with the payload; alongside other validation failures you get INVALID_PAYLOAD with every issue listed in errorList |
| PROVIDER_NUMBER_IN_USE | That provider number is already registered to another organisation. A provider number belongs to one practice at a time; we do not say who holds it |
| PROVIDER_NOT_FOUND | No provider record with that number in your organisation |
| PROVIDER_HAS_ACTIVE_ROUTING | The provider cannot be retired while a lab is still routing its results. Contact support to wind routing down first |
| INVALID_SIGNATURE | The signature is not a base64-encoded PNG, or is too large. Checked on the decoded bytes, so a renamed JPEG is rejected |
| MISSING_SIGNATURE | A signature must be on file for this provider before routing can be requested |
| INVALID_PROVIDER_DETAILS | Provider name and address are both required |
| MISSING_ATTESTATION | A routing request needs attestedBy and a valid doctorSignedAt |
| NO_VENDORS_SELECTED | A routing request needs at least one lab in vendorIds |
| UNKNOWN_VENDOR | One or more of the labs in vendorIds is unknown or not currently enabled |
| WEBHOOK_NOT_FOUND | No webhook with that id in your organisation. Returned for another organisation's webhook too |
| DUPLICATE_WEBHOOK_SUBSCRIPTION | (409) This URL already receives one of these events from another of your webhooks. Edit that webhook instead of adding a second one |
| INVALID_WEBHOOK_URL | The webhook URL is not valid: not https, not a URL, carries credentials, or points at a private or internal address. The message says which |
| INVALID_WEBHOOK_EVENTS | The event list is empty or names an unknown event. The message names it |
Pagination
Every list endpoint pages the same way, with a cursor:
- Make the first request without a
cursor. Setcountfor the page size if you want something other than the default. - Every page that has records carries a
cursorobject:{ "cursor": "…", "count": 20, "resultCount": 20 }.countis the page size the server applied, andresultCountis how many records this page has. - Pass
cursor.cursorback as thecursorquery parameter, with the same filters andsort, for the next page. - Stop when
cursor.resultCountis less thancursor.count, or when a page comes back with nocursor(an empty page).
let cursor = null
do {
const page = await get(`/v1/referrals?count=20${cursor ? `&cursor=${cursor}` : ''}`)
handle(page.referrals)
cursor =
page.cursor && page.cursor.resultCount === page.cursor.count
? page.cursor.cursor
: null
} while (cursor)
cursor.count, not the count you sentA larger count than an endpoint allows is capped, not refused: ask for 50
on an endpoint whose maximum is 20 and you get pages of 20, with
cursor.count: 20. An integration that compares resultCount with the count it
sent sees 20 is less than 50 and stops after the first page, missing everything
after it. Before September 2026, cursor.count always read 20 whatever page
size applied, so if your integration was written against the value you send,
check it against the rule above.
| Endpoint | Default count | Maximum count |
|---|---|---|
| List Referrals, List Results, List Providers, List Bundles | 20 | 20 |
| List Collection Centres | 50 | 10,000 |
Collection centres also return totalResults, the number of records across every
page.
A cursor is a position in one result set. It only works on the endpoint that
issued it, and only with the same filters and sort. A negative count, a sort
other than asc or desc, or a cursor that is unreadable or came from another
endpoint returns 400 INVALID_PARAMETER, with the problem in errorList:
{
"error": true,
"code": "INVALID_PARAMETER",
"message": "The parameter provided is invalid",
"errorList": [
"cursor is not one this endpoint issued. Use the cursor from a previous page, or leave it out to start from the beginning"
]
}
Metadata
Custom metadata can be attached to referrals for correlation purposes:
{
"metadata": {
"internalId": "your-internal-id",
"customField": "custom-value"
}
}
Metadata is returned with the referral and results objects, allowing you to correlate Eirly records with your internal systems.
Rate Limiting
API rate limits apply per organisation. Contact Eirly if you require higher limits.
Support
For API support:
- Review the Swagger Documentation for interactive examples
- See Webhooks for real-time notifications
- Contact the Eirly API team for integration assistance