Getting started
The sandbox API lives at https://sandbox-api-dev.md4labs.com. It speaks JSON over HTTPS: send Content-Type: application/json with every body, and expect JSON back. Times are ISO 8601 in UTC, and ids are opaque strings: store them as given.
The examples on this site use two shell variables. Set them once in your terminal:
export MD4_API=https://sandbox-api-dev.md4labs.com
export MD4_KEY=md4_test_your_key_here
Then check your key:
curl "$MD4_API/v1/me" -H "Authorization: Bearer $MD4_KEY"
{
"organization": { "id": "cmhx0q4zc0000s6d8a1b2c3d4", "name": "Acme Diagnostics" },
"environment": "sandbox",
"scopes": ["subjects:read", "subjects:write", "orders:read", "orders:write", "results:read", "webhooks:manage"],
"key": { "prefix": "md4_test_AbC123", "name": "Staging server" }
}
Call the API from your servers. An API key is a password for your organization. Keep it in a secrets manager, never in a browser app, a mobile app or source control. The API doesn't accept calls from other websites' browser code.
Authentication
Every request carries an API key as a bearer token:
Authorization: Bearer md4_test_AbC123...
Sandbox keys start with md4_test_ and live keys (later) with md4_live_; neither works in the other place. Create keys in the console. We show a key once, when you create it, and keep only a fingerprint of it, so we can't show it again: if you lose one, create another and revoke the old one. Each organization can have 10 active keys.
Scopes
A key can do only what its scopes allow. Give each program the scopes it needs and no more. GET /v1/me, GET /v1/tests and GET /v1/openapi.json work with any valid key.
| Scope | Lets the key |
|---|---|
subjects:read | List and read people and pets: GET /v1/subjects, GET /v1/subjects/{id} |
subjects:write | Add people and pets: POST /v1/subjects |
orders:read | List and read orders with their tracking: GET /v1/orders, GET /v1/orders/{id} |
orders:write | Place and cancel orders, and simulate the lab: POST /v1/orders, POST /v1/orders/{id}/cancel, POST /v1/sandbox/orders/{id}/simulate |
results:read | Read results: GET /v1/orders/{id}/results |
webhooks:manage | Add, list and remove webhook endpoints: /v1/webhook-endpoints |
A missing, malformed, revoked or expired key gets 401 with code invalid_api_key. A valid key without the scope gets 403 with code insufficient_scope.
Rotating a key
- Create a new key with the same scopes.
- Deploy it to your servers.
- When the console's request log shows no more calls with the old key, revoke it. Revoking takes effect at once.
Errors
Errors use problem details (RFC 9457), sent as application/problem+json:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
X-Request-Id: f3Jq2hYWoAMEbVw=
{
"type": "https://developers.md4labs.com/errors#invalid_request",
"title": "Invalid request",
"status": 400,
"detail": "species is required for pets",
"code": "invalid_request",
"requestId": "f3Jq2hYWoAMEbVw=",
"errors": [{ "path": "species", "message": "species is required for pets" }]
}
- code
- A stable, machine-readable code. Branch on this, not on
titleordetail. Every code is listed with what to do about it. - detail
- What went wrong, in plain words, for your logs.
- errors
- For validation errors, one entry per field, with its
path(for exampleshipTo.zip). - requestId
- The id of this request. Every response, success or not, also carries it in the
X-Request-Idheader. Log it, and quote it when you write to us: it's how we find your call.
What to retry
- 4xx: fix the request first. Retrying the same request gets the same answer, except
429(wait, then retry) and409from a record that changed underneath you (fetch it, then decide). - 5xx and network failures: retry with backoff. For
POST /v1/subjectsandPOST /v1/orders, send the sameIdempotency-Keyso a retry never creates a second record.
Idempotency
Networks fail after a request has arrived but before you see the answer. To retry a create safely, send an Idempotency-Key header with POST /v1/subjects and POST /v1/orders: a unique value of up to 100 characters (a UUID is ideal) for each thing you mean to create.
curl -X POST "$MD4_API/v1/subjects" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6c0e8a52-1f0e-4d5e-9a63-0b1f3a2ec4b6" \
-d '{"kind":"person","firstName":"Jordan","lastName":"Rivera","dateOfBirth":"1988-04-12","sex":"female"}'
- For 24 hours, the same key with the same body returns the first response again (status and body), with the header
Idempotent-Replayed: true. Nothing is created twice. - The same key with a different body or endpoint gets
409 idempotency_conflict. - A response we failed to produce (
5xx) isn't remembered, so retrying with the same key does the work. - Keys belong to your organization: two of your programs sharing a key share its result.
Other actions are safe to repeat on their own: cancelling an already cancelled order answers 409 not_cancellable, which you can treat as done.
Pagination
GET /v1/subjects and GET /v1/orders return a page at a time, newest first:
{
"data": [ { "id": "cmhx1n8yb0004s6d8h2m1p0qe", "...": "..." } ],
"nextCursor": "cmhx1k2a40001s6d8f3q9z7vb"
}
limit: how many to return, 1 to 100 (default 25).cursor: thenextCursorfrom the page before. Treat it as opaque, and keep the same filters while paging.nextCursorisnullon the last page.- Filters:
externalId(your own id) on both, andstatuson orders.
curl "$MD4_API/v1/orders?status=results_ready&limit=100" -H "Authorization: Bearer $MD4_KEY"
let cursor = null;
do {
const url = new URL("/v1/orders", process.env.MD4_API);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.MD4_KEY}` } });
if (!res.ok) throw new Error(`MD4 API ${res.status}, request ${res.headers.get("x-request-id")}`);
const page = await res.json();
for (const order of page.data) handle(order);
cursor = page.nextCursor;
} while (cursor);
Rate limits
Each key may make 120 requests a minute (a rolling 60 seconds), errors included. Every answer tells you where you stand:
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed in the window (120). |
RateLimit-Remaining | Requests left in the current window. |
RateLimit-Reset | Seconds until the window has fully reset. |
Over the limit, the API answers 429 with code rate_limited and a Retry-After header in seconds. Wait that long, then retry; slow down if RateLimit-Remaining runs low. Under very heavy load the gateway in front of the API can also answer 429 with a short {"message": "Too Many Requests"} body: treat it the same way. Read the headers rather than hard-coding 120: the limit may change.
Orders and the simulated lab
An order is one or more tests for one person or pet. With shipKit: true (the default), the lab mails an at-home collection kit to shipTo; with shipKit: false the sample reaches the lab another way. The order's status moves along as the kit and sample do, and tracking records every step, oldest first, with a plain-words next:
receivedsent_to_labkit_shippedkit_deliveredsample_receivedresults_ready
| Status | What it means |
|---|---|
received | We have the order and are sending it to the lab. |
sent_to_lab | The lab has the order and is preparing the kit. In the sandbox, a new order is approved at once and starts here; the live API waits for a physician or vet to approve it first. |
kit_shipped | The kit is on its way. tracking.carrier and tracking.trackingNumber are set. |
kit_delivered | The kit arrived. The sample goes back with the prepaid label. |
sample_received | The lab is testing the sample. |
results_ready | Results are ready at GET /v1/orders/{id}/results. |
sample_not_testable | The lab couldn't test the sample (for example, not enough of it). A replacement kit is needed: place a new order. |
cancelled | You cancelled the order. |
New statuses may be added within v1: show an unknown one as its tracking.next text rather than failing.
Simulating the lab
In the sandbox nothing moves on its own. POST /v1/sandbox/orders/{id}/simulate plays the lab's side, one event at a time, and fires the same webhooks the live API will. It needs the orders:write scope and exists only in the sandbox.
| Event | Allowed when the order is | Moves it to | Webhooks |
|---|---|---|---|
kit_shipped | sent_to_lab, with shipKit: true | kit_shipped | order.updated |
kit_delivered | kit_shipped | kit_delivered | order.updated |
sample_received | kit_delivered, or sent_to_lab with shipKit: false | sample_received | order.updated |
results_ready | sample_received | results_ready | order.updated, order.results_ready |
sample_not_testable | sample_received | sample_not_testable | order.updated |
results_corrected | results_ready | results_ready, with the results' version one higher | order.updated, order.results_ready |
curl -X POST "$MD4_API/v1/sandbox/orders/$ORDER_ID/simulate" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-d '{"event":"kit_shipped"}'
An event that doesn't fit the order's status gets 409 invalid_transition, and the problem's allowed field lists the events that would:
{
"type": "https://developers.md4labs.com/errors#invalid_transition",
"title": "Conflict",
"status": 409,
"detail": "From sent to lab, you can simulate: kit_shipped.",
"code": "invalid_transition",
"requestId": "f3Jq2hYWoAMEbVw=",
"allowed": ["kit_shipped"]
}
Placing and cancelling
Placing an order fires order.updated. You can cancel with POST /v1/orders/{id}/cancel until the sample reaches the lab, that is while the order is received, sent_to_lab, kit_shipped or kit_delivered. Cancelling fires order.cancelled and order.updated. Later, the answer is 409 not_cancellable.
Each test fits people, pets or both (audience in GET /v1/tests); ordering one that doesn't fit the subject gets 400 invalid_request naming the tests.
Results
GET /v1/orders/{id}/results needs the results:read scope. Until the order is results_ready it answers "status": "pending" with no values.
Our flag decides; the lab's own flag and range are kept for reference. Each value is judged on MD4 Labs reference ranges for that subject: for a person, or for the pet's species. The lab's printed flag and range come along in lab so you can show or audit them, but they never decide the flag.
{
"orderId": "cmhx1n8yb0004s6d8h2m1p0qe",
"status": "ready",
"version": 1,
"reportedAt": "2026-11-07T15:04:05.000Z",
"values": [
{
"code": "CHOL",
"name": "Cholesterol, total",
"value": "254",
"unit": "mg/dL",
"flag": "normal",
"range": { "low": 110, "high": 320 },
"lab": { "flag": "H", "range": "0-199" }
},
{
"code": "ALT",
"name": "Alanine aminotransferase",
"value": "141",
"unit": "U/L",
"flag": "high",
"range": { "low": 10, "high": 125 },
"lab": { "flag": "H", "range": "7-56" }
}
]
}
Here, for a dog, cholesterol of 254 mg/dL is normal on our canine range (110 to 320) even though the lab's printed human range marks it high.
- value
- The result as reported, as a string (some results aren't plain numbers).
- flag
- Our judgement:
normal,low,high,critical_low,critical_high, ornot_judgedwhen we have no range for this analyte and subject (show the value without a judgement). - range
- The range we judged it on, or
nullwhennot_judged. Either bound can benull. - lab
- The lab's own flag (such as
HorL) and printed range, for reference only. - version
- Goes up when the lab sends a correction. A correction fires
order.results_readyagain: replace what you stored with the new values.
Sandbox values are synthetic but realistic, and the same order always gets the same values. About one in five lands just outside our range, so you'll see flags.
Webhooks
Rather than polling, register an https endpoint and we'll post an event to it whenever one of your orders changes. Endpoints are managed through the API with the webhooks:manage scope; the console shows every delivery.
curl -X POST "$MD4_API/v1/webhook-endpoints" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/md4/webhooks","events":["order.updated","order.results_ready","order.cancelled"]}'
The answer includes the endpoint's signing secret (whsec_...). It's shown only this once: store it with your other secrets. To change it, create a new endpoint, switch over, then delete the old one with DELETE /v1/webhook-endpoints/{id}. Each organization can have 10 endpoints.
Events and payload
| Event | Sent when |
|---|---|
order.updated | An order is placed or its status changes (including cancellation and results). |
order.results_ready | Results are ready, or a correction replaced them. |
order.cancelled | An order was cancelled. |
Each delivery is a POST with a JSON body; data.order is the order as GET /v1/orders/{id} returns it.
POST /md4/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: MD4-Webhooks/1
MD4-Event: order.results_ready
MD4-Delivery-Id: cmhx1r5tq0007s6d8k4n2w8yc
MD4-Signature: t=1762527845,v1=5f2b9c4e0d7a1b3c6e8f0a2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e3f5a7b9c1d
{
"id": "evt_6f1c2a9b0e4d7c3a5b8e9f01",
"type": "order.results_ready",
"createdAt": "2026-11-07T15:04:05.000Z",
"data": {
"order": {
"id": "cmhx1n8yb0004s6d8h2m1p0qe",
"status": "results_ready",
"subjectId": "cmhx1k2a40001s6d8f3q9z7vb",
"tests": ["PET-WELLNESS"],
"shipKit": true,
"externalId": "order-1001",
"tracking": {
"carrier": "UPS",
"trackingNumber": "1Z8F3A9C27D41B6E05",
"steps": [
{ "status": "received", "at": "2026-11-07T14:58:10.120Z", "detail": null },
{ "status": "sent_to_lab", "at": "2026-11-07T14:58:10.121Z", "detail": "Approved and sent to the lab" },
{ "status": "kit_shipped", "at": "2026-11-07T15:01:02.410Z", "detail": "UPS 1Z8F3A9C27D41B6E05" },
{ "status": "kit_delivered", "at": "2026-11-07T15:02:11.032Z", "detail": null },
{ "status": "sample_received", "at": "2026-11-07T15:03:30.877Z", "detail": null },
{ "status": "results_ready", "at": "2026-11-07T15:04:05.000Z", "detail": null }
],
"next": "Results are ready."
},
"resultsAvailable": true,
"createdAt": "2026-11-07T14:58:10.120Z",
"updatedAt": "2026-11-07T15:04:05.000Z"
}
}
}
Deliveries can arrive more than once, and not always in order. The event id stays the same across retries and resends, so skip ids you've already handled; and compare data.order.updatedAt (or fetch the order) before acting on an older event.
Verifying signatures
Every delivery is signed with your endpoint's secret, so you can tell it came from us and wasn't changed:
MD4-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
- Split the header on commas and read
tandv1. - Compute HMAC-SHA256 with your secret over the timestamp, a dot, and the raw body exactly as received (before any JSON parsing), as hex.
- Compare it with
v1in constant time. - Reject deliveries whose
tis more than 5 minutes from your clock, so an old delivery can't be replayed.
// npm install express
const crypto = require("node:crypto");
const express = require("express");
const TOLERANCE_SECONDS = 300;
function verifyMd4Signature(rawBody, header, secret) {
let timestamp = null;
const signatures = [];
for (const part of String(header || "").split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") timestamp = Number(value);
if (key === "v1" && value) signatures.push(value);
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // the exact bytes we sent
.digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
const app = express();
// express.raw keeps the body as bytes: a JSON parser would change them.
app.post("/md4/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyMd4Signature(req.body, req.get("MD4-Signature"), process.env.MD4_WEBHOOK_SECRET)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// event.id is the same on retries and resends: skip ids you've already handled.
// event.type is order.updated, order.results_ready or order.cancelled.
queueForProcessing(event); // answer within 5 seconds; do slow work afterwards
res.sendStatus(204);
});
app.listen(3000);
# pip install flask
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 300
def verify_md4_signature(raw_body: bytes, header: str, secret: str) -> bool:
timestamp, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
app = Flask(__name__)
@app.post("/md4/webhooks")
def md4_webhook():
raw = request.get_data() # the exact bytes we sent
if not verify_md4_signature(raw, request.headers.get("MD4-Signature", ""), os.environ["MD4_WEBHOOK_SECRET"]):
abort(400)
event = json.loads(raw)
# event["id"] is the same on retries and resends: skip ids you've already handled.
queue_for_processing(event) # answer within 5 seconds; do slow work afterwards
return "", 204
Retries and resending
A delivery succeeds when your endpoint answers with a 2xx status within 5 seconds. Anything else counts as a failed attempt: another status, a redirect (we don't follow them), a timeout, or no connection. We then try again with backoff, up to 6 attempts in all:
| Attempt | When |
|---|---|
| 1 | Right away |
| 2 | About 30 seconds after the first failure |
| 3 | About 2 minutes later |
| 4 | About 10 minutes later |
| 5 | About 15 minutes later |
| 6 | About 15 minutes later; if this fails too, the delivery is marked failed |
The console lists recent deliveries with their status, attempts, last status code, last error and payload. Send again queues a fresh delivery of the same event, for example after you've fixed your endpoint. Deleting an endpoint stops its pending deliveries.
Endpoint URLs
- Must be
https, on the standard port (443), with no user name or password in the URL. - Must be on a public host. Localhost,
.localand.internalnames, and private, loopback, link-local (cloud metadata) and carrier-grade NAT addresses are refused. - We check the address again, DNS included, right before every delivery, so a name that later points somewhere private stops receiving events.
To receive events on your own machine during development, expose your handler through a public https tunnel and register the tunnel's URL.
Walkthrough with curl
From an empty organization to results, step by step. With jq installed, the export lines save each id for the next step; without it, copy the id from the answer.
1. Set up
export MD4_API=https://sandbox-api-dev.md4labs.com
export MD4_KEY=md4_test_your_key_here
curl "$MD4_API/v1/me" -H "Authorization: Bearer $MD4_KEY"
2. See what you can order
curl "$MD4_API/v1/tests" -H "Authorization: Bearer $MD4_KEY"
3. Add a pet (or a person)
export SUBJECT_ID=$(curl -s -X POST "$MD4_API/v1/subjects" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"kind":"pet","firstName":"Rex","lastName":"Rivera","dateOfBirth":"2019-04-12","sex":"male","species":"canine","breed":"Labrador Retriever","externalId":"pet-1001"}' \
| jq -r .id)
echo "$SUBJECT_ID"
4. Order a test, with a kit mailed home
export ORDER_ID=$(curl -s -X POST "$MD4_API/v1/orders" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"subjectId":"'"$SUBJECT_ID"'","tests":["PET-WELLNESS"],"shipKit":true,"shipTo":{"name":"Jordan Rivera","line1":"1 Example St","city":"Augusta","state":"GA","zip":"30901","phone":"7065550100"},"externalId":"order-1001"}' \
| jq -r .id)
echo "$ORDER_ID"
5. Get notified (optional)
curl -X POST "$MD4_API/v1/webhook-endpoints" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/md4/webhooks","events":["order.updated","order.results_ready","order.cancelled"]}'
6. Play the lab
for event in kit_shipped kit_delivered sample_received results_ready; do
curl -s -X POST "$MD4_API/v1/sandbox/orders/$ORDER_ID/simulate" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-d "{\"event\":\"$event\"}" | jq -r '.status'
done
7. Follow the order
curl "$MD4_API/v1/orders/$ORDER_ID" -H "Authorization: Bearer $MD4_KEY"
8. Read the results
curl "$MD4_API/v1/orders/$ORDER_ID/results" -H "Authorization: Bearer $MD4_KEY"
9. Try a correction, or a cancellation
# The lab corrects a result: the version goes up and order.results_ready fires again.
curl -X POST "$MD4_API/v1/sandbox/orders/$ORDER_ID/simulate" \
-H "Authorization: Bearer $MD4_KEY" \
-H "Content-Type: application/json" \
-d '{"event":"results_corrected"}'
# Cancel a different order before its sample reaches the lab.
curl -X POST "$MD4_API/v1/orders/$OTHER_ORDER_ID/cancel" -H "Authorization: Bearer $MD4_KEY"
Each call shows up in the console's request log with its status, time and request ID.
Versioning
The major version is in the path: /v1. Within v1 we only add things:
- new endpoints, and new optional request fields;
- new fields in responses and webhook payloads;
- new values in lists such as order statuses, events, flags and error codes.
So build tolerant clients: ignore fields you don't know, and handle an unknown status or code without failing. Anything that would break a working client comes as /v2, announced well ahead, with time to move. The reference and its OpenAPI document carry the version number (info.version).
Changelog
v1.0.0: first release
November 2026
- API keys with scopes, created and revoked in the console.
- The test catalog; people and pets; orders with kit tracking and cancellation.
- Results judged on MD4 Labs ranges, with the lab's flag and range kept for reference; corrections with versions.
- Signed webhooks with retries, a delivery log and resending.
- The sandbox's simulated lab.
- Problem-details errors with request ids, idempotency keys, cursor pagination and rate-limit headers.