Guides

Everything about the MD4 Labs Partner API in one page: how to authenticate, how errors look, how to retry safely, and how an order moves from placed to results.

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.

ScopeLets the key
subjects:readList and read people and pets: GET /v1/subjects, GET /v1/subjects/{id}
subjects:writeAdd people and pets: POST /v1/subjects
orders:readList and read orders with their tracking: GET /v1/orders, GET /v1/orders/{id}
orders:writePlace and cancel orders, and simulate the lab: POST /v1/orders, POST /v1/orders/{id}/cancel, POST /v1/sandbox/orders/{id}/simulate
results:readRead results: GET /v1/orders/{id}/results
webhooks:manageAdd, 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

  1. Create a new key with the same scopes.
  2. Deploy it to your servers.
  3. 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 title or detail. 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 example shipTo.zip).
requestId
The id of this request. Every response, success or not, also carries it in the X-Request-Id header. 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) and 409 from a record that changed underneath you (fetch it, then decide).
  • 5xx and network failures: retry with backoff. For POST /v1/subjects and POST /v1/orders, send the same Idempotency-Key so 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: the nextCursor from the page before. Treat it as opaque, and keep the same filters while paging.
  • nextCursor is null on the last page.
  • Filters: externalId (your own id) on both, and status on 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:

HeaderMeaning
RateLimit-LimitRequests allowed in the window (120).
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds 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:

  1. received
  2. sent_to_lab
  3. kit_shipped
  4. kit_delivered
  5. sample_received
  6. results_ready
StatusWhat it means
receivedWe have the order and are sending it to the lab.
sent_to_labThe 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_shippedThe kit is on its way. tracking.carrier and tracking.trackingNumber are set.
kit_deliveredThe kit arrived. The sample goes back with the prepaid label.
sample_receivedThe lab is testing the sample.
results_readyResults are ready at GET /v1/orders/{id}/results.
sample_not_testableThe lab couldn't test the sample (for example, not enough of it). A replacement kit is needed: place a new order.
cancelledYou 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.

EventAllowed when the order isMoves it toWebhooks
kit_shippedsent_to_lab, with shipKit: truekit_shippedorder.updated
kit_deliveredkit_shippedkit_deliveredorder.updated
sample_receivedkit_delivered, or sent_to_lab with shipKit: falsesample_receivedorder.updated
results_readysample_receivedresults_readyorder.updated, order.results_ready
sample_not_testablesample_receivedsample_not_testableorder.updated
results_correctedresults_readyresults_ready, with the results' version one higherorder.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, or not_judged when we have no range for this analyte and subject (show the value without a judgement).
range
The range we judged it on, or null when not_judged. Either bound can be null.
lab
The lab's own flag (such as H or L) and printed range, for reference only.
version
Goes up when the lab sends a correction. A correction fires order.results_ready again: 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

EventSent when
order.updatedAn order is placed or its status changes (including cancellation and results).
order.results_readyResults are ready, or a correction replaced them.
order.cancelledAn 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>">
  1. Split the header on commas and read t and v1.
  2. Compute HMAC-SHA256 with your secret over the timestamp, a dot, and the raw body exactly as received (before any JSON parsing), as hex.
  3. Compare it with v1 in constant time.
  4. Reject deliveries whose t is 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:

AttemptWhen
1Right away
2About 30 seconds after the first failure
3About 2 minutes later
4About 10 minutes later
5About 15 minutes later
6About 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, .local and .internal names, 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.