Developers

Order labs and pull results from your own platform.

API access is enabled per account. Talk to us to get test keys for the sandbox, then switch to live keys when your integration works end-to-end.

API reference

A REST API to provision clinics, manage patients, place lab orders, and pull results. Base URL:

https://api.panelroute.com/v1

All requests and responses are JSON. A test key (prk_test_…) runs against an isolated sandbox — no real labs, no charges, no billing — and a live key (prk_live_…) is production. See Sandbox to run a full order end-to-end before you go live.

Import the OpenAPI spec to generate a client, or the Postman collection to try every endpoint — set the apiKey variable to your test key to hit the sandbox.

Sandbox (test mode)

Use a test key to build and test your integration against a fully isolated sandbox:

  • Test patients and orders are kept completely separate from live data — a test key only ever sees test data, and vice versa.
  • Orders placed with a test key are never charged, never billed, and never sent to a real lab. /v1/usage stays empty in test mode.
  • You drive the order lifecycle yourself with POST /v1/orders/{id}/simulate — advance to collected, resulted, or delivered on demand. On delivery you get canned results (with one out-of-range marker) and the same order.status_changed / order.results_ready webhooks you'll get in production.

A full test run — create a patient, order, deliver it, pull results:

# 1. Create a sandbox patient (test key → test data)
curl https://api.panelroute.com/v1/patients -H "Authorization: Bearer prk_test_…" \
  -H "Content-Type: application/json" \
  -d '{ "provider_id":"…","first_name":"Test","last_name":"Patient",
        "dob":"1990-01-01","gender":"female","email":"t@example.com",
        "phone":"6025551234","address_line1":"123 Main St",
        "address_city":"Phoenix","address_state":"AZ","address_zip":"85004" }'

# 2. Place a sandbox order (no charge, no lab)
curl https://api.panelroute.com/v1/orders -H "Authorization: Bearer prk_test_…" \
  -H "Content-Type: application/json" \
  -d '{ "provider_id":"…","patient_id":"…","panel_id":"…",
        "consents":{ "telehealth":true,"hipaa_authorization":true } }'
# → { "order": { "id":"…","status":"processing" } }

# 3. Drive it to delivered (fires your webhooks, attaches results)
curl https://api.panelroute.com/v1/orders/{id}/simulate -H "Authorization: Bearer prk_test_…" \
  -H "Content-Type: application/json" -d '{ "to":"delivered" }'

# 4. Pull the (canned) results
curl https://api.panelroute.com/v1/orders/{id}/results -H "Authorization: Bearer prk_test_…"

When your integration works end-to-end in the sandbox, swap in your prk_live_… key — the requests are identical.

Authentication

Send your API key (from your dashboard, once your account has API access) as a Bearer token on every request. Keys are secret — use them server-side only.

Authorization: Bearer prk_live_…

Errors

Errors return a consistent shape with an HTTP status. type is one of invalid_request (400), unauthorized (401), forbidden (403), not_found (404), conflict (409), api_error (5xx).

{ "error": { "type": "invalid_request", "message": "provider_id is required." } }

Idempotency

Send an Idempotency-Key header (a UUID you generate) on POST /v1/orders. A retry with the same key replays the original response instead of placing a second order. Reusing a key with a different body returns 409.

Idempotency-Key: 8ea40aa2-7727-4172-82e8-a58715fa9b6d

Pagination

List endpoints return up to ?limit= (default 50, max 100) items plus has_more and next_cursor. Pass ?starting_after=<id> to page.

Clinics

Provision and list your clinics. Each clinic is where patients and orders live.

POST/v1/providers

Provision a clinic. Auto-activated. Idempotent on external_clinic_id (your own id for the clinic).

Request

curl https://api.panelroute.com/v1/providers \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "org_name": "Downtown Family Clinic",
    "external_clinic_id": "clinic_8842",
    "contact_email": "ops@downtownclinic.com",
    "npi": "1982749531",
    "licensed_states": ["AZ"],
    "baa_attested": true
  }'

Response

{
  "provider": {
    "id": "e3eb2bdf-f6b2-4f8b-b5cd-66a7e09c63a5",
    "org_name": "Downtown Family Clinic",
    "status": "active",
    "external_clinic_id": "clinic_8842"
  }
}
GET/v1/providers

List your clinics.

Request

curl https://api.panelroute.com/v1/providers -H "Authorization: Bearer prk_live_…"

Response

{
  "data": [
    { "id": "e3eb2bdf-…", "org_name": "Downtown Family Clinic",
      "status": "active", "external_clinic_id": "clinic_8842",
      "npi": "1982749531", "licensed_states": ["AZ"] }
  ]
}
GET/v1/providers/{id}

Retrieve one clinic.

Request

curl https://api.panelroute.com/v1/providers/e3eb2bdf-… -H "Authorization: Bearer prk_live_…"

Response

{ "provider": { "id": "e3eb2bdf-…", "org_name": "Downtown Family Clinic",
    "status": "active", "contact_email": "ops@downtownclinic.com", … } }
PATCH/v1/providers/{id}

Update a clinic — org_name, contact_email, npi, licensed_states. Status and billing are platform-managed.

Request

curl -X PATCH https://api.panelroute.com/v1/providers/e3eb2bdf-… \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "contact_email": "newops@downtownclinic.com" }'

Response

{ "provider": { "id": "e3eb2bdf-…", "contact_email": "newops@downtownclinic.com", … } }

Patients

Patients belong to a clinic. Email, phone, and full address are required so orders are ready to ship.

POST/v1/patients

Create a patient under a clinic.

Request

curl https://api.panelroute.com/v1/patients \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "provider_id": "e3eb2bdf-…",
    "first_name": "Jane", "last_name": "Doe",
    "dob": "1990-04-12", "gender": "female",
    "email": "jane@example.com", "phone": "6025551234",
    "address_line1": "123 Main St", "address_city": "Phoenix",
    "address_state": "AZ", "address_zip": "85004"
  }'

Response

{
  "patient": {
    "id": "cb9f568d-…", "provider_id": "e3eb2bdf-…",
    "first_name": "Jane", "last_name": "Doe",
    "dob": "1990-04-12", "gender": "female"
  }
}
GET/v1/patients

List patients. Filter by ?provider_id= and search by ?q=.

Request

curl "https://api.panelroute.com/v1/patients?provider_id=e3eb2bdf-…" \
  -H "Authorization: Bearer prk_live_…"

Response

{ "data": [ { "id": "cb9f568d-…", "first_name": "Jane", … } ],
  "has_more": false, "next_cursor": null }
GET/v1/patients/{id}

Retrieve one patient.

Request

curl https://api.panelroute.com/v1/patients/cb9f568d-… -H "Authorization: Bearer prk_live_…"

Response

{ "patient": { "id": "cb9f568d-…", "first_name": "Jane", … } }
PATCH/v1/patients/{id}

Update contact, address, or name (partial). dob/gender changes go through support. Required fields can’t be blanked — patients must stay order-ready.

Request

curl -X PATCH https://api.panelroute.com/v1/patients/cb9f568d-… \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "6025559999" }'

Response

{ "patient": { "id": "cb9f568d-…", "phone": "6025559999", … } }

Panels

What a clinic can order — standard panels plus its own custom panels. wholesale_price is what you pay; retail_price is your marked-up price (set the markup via /v1/markup). Prices are per-panel — every order additionally carries a fixed $15.00 Lab Processing Fee.

GET/v1/panels

List orderable panels: the standard catalog, your shared panels (marked "shared": true), plus a clinic’s own custom panels with ?provider_id=.

Request

curl "https://api.panelroute.com/v1/panels?provider_id=e3eb2bdf-…" \
  -H "Authorization: Bearer prk_live_…"

Response

{
  "data": [
    { "id": "31407505-…", "type": "custom", "name": "TRT Panel (Male)",
      "collection_method": "walk_in_test", "lab_name": "Labcorp", "marker_count": 4,
      "markers": [
        { "marker_id": 123, "name": "Testosterone, Total", "slug": "testosterone-total" },
        { "marker_id": 124, "name": "Testosterone, Free", "slug": "testosterone-free" },
        { "marker_id": 201, "name": "Estradiol", "slug": "estradiol" },
        { "marker_id": 305, "name": "Sex Hormone Binding Globulin", "slug": "shbg" }
      ],
      "wholesale_price": "54.00", "retail_price": "70.20" }
  ]
}
GET/v1/panels/{id}

Retrieve one panel with its full biomarker list (markers), priced as on the list. Pass ?provider_id= for a clinic’s own custom panel. Archived custom panels resolve too (orderable: false).

Request

curl "https://api.panelroute.com/v1/panels/31407505-…?provider_id=e3eb2bdf-…" \
  -H "Authorization: Bearer prk_live_…"

Response

{ "panel": { "id": "31407505-…", "type": "custom", "name": "TRT Panel (Male)",
    "lab_name": "Labcorp", "marker_count": 4,
    "markers": [ { "marker_id": 123, "name": "Testosterone, Total", "slug": "testosterone-total" }, … ],
    "orderable": true, "wholesale_price": "54.00", "retail_price": "70.20" } }
GET/v1/biomarkers

Search the orderable biomarker catalog — or omit q and pass lab to page through that lab’s full catalog (page, size ≤ 100; keep going until page equals pages). Each result’s marker_id + provider_id plug straight into POST /v1/panels — the discovery half of a panel-builder UI. provider_id codes are lab-specific, so pass ?lab= (quest | labcorp | bioreference | sonora_quest | ihd) when building; all of a panel’s markers must come from one lab. Every marker carries its own wholesale_price + retail_price, markup included (add ?provider_id= to price as that clinic’s own panel). Marker prices are estimates: a panel is priced as one lab test when you create it, which can differ from the sum of its markers, and every order adds the flat lab_processing_fee once. price_varies: true means the lab may adjust that marker by condition (e.g. patient state).

Request

curl "https://api.panelroute.com/v1/biomarkers?lab=labcorp&page=1&size=100" \
  -H "Authorization: Bearer prk_live_…"

Response

{ "data": [ { "marker_id": 123, "name": "Testosterone, Total",
    "slug": "testosterone-total", "unit": "ng/dL",
    "provider_id": "TESTOSTERONE_TOTAL", "lab": "labcorp",
    "wholesale_price": "34.82", "retail_price": "45.27", "price_varies": false } ],
  "page": 1, "size": 100, "pages": 11, "lab_processing_fee": "15.00" }
GET/v1/coverage

How many patient service centers (walk-in draw sites) each lab has within range of a zip — check before ordering so the patient goes to a lab that is nearby. zip is required (5 digits; convert a city to a zip first); radius is 10 | 20 | 25 | 50 | 100 miles (default 25); lab (quest | labcorp | bioreference | sonora_quest) may repeat, default all. locations_within_radius is null when that lab is not offered for the area at all (e.g. Quest and Labcorp in NY, NJ and RI); 0 means offered but no locations within the radius.

Request

curl "https://api.panelroute.com/v1/coverage?zip=85004&radius=25" \
  -H "Authorization: Bearer prk_live_…"

Response

{ "zip": "85004", "radius_miles": 25, "data": [
    { "lab": "quest", "lab_name": "Quest Diagnostics",
      "locations_within_radius": 36, "available": true },
    { "lab": "labcorp", "lab_name": "LabCorp",
      "locations_within_radius": 16, "available": true } ] }
POST/v1/panels

Create a custom panel. Pass provider_id for a single clinic, or "shared": true for a partner-wide panel every one of your clinics can order — your standard menu. Auto-approves if the lab activates it immediately; otherwise panel.approved fires once orderable (usually within a day). Live keys only.

Request

curl https://api.panelroute.com/v1/panels \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "shared": true,
    "name": "TRT Panel (Male)",
    "collection_method": "walk_in_test",
    "markers": [ { "marker_id": 123, "provider_id": "TESTOSTERONE_TOTAL" } ]
  }'

Response

{ "panel": { "id": "31407505-…", "type": "custom", "shared": true,
    "name": "TRT Panel (Male)", "approval_status": "pending", "orderable": false } }
GET/v1/markup

Your retail markup config: the default plus any per-panel overrides.

Request

curl https://api.panelroute.com/v1/markup -H "Authorization: Bearer prk_live_…"

Response

{ "default_markup_percent": 30,
  "overrides": [ { "panel_id": "31407505-…", "markup_percent": 45 } ] }
PUT/v1/markup

Set the default retail markup and/or per-panel overrides (markup_percent: null removes one). Applies to future orders — wholesale pricing is not writable.

Request

curl -X PUT https://api.panelroute.com/v1/markup \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "default_markup_percent": 30,
        "overrides": [ { "panel_id": "31407505-…", "markup_percent": 45 } ] }'

Response

{ "default_markup_percent": 30,
  "overrides": [ { "panel_id": "31407505-…", "markup_percent": 45 } ] }

Orders

Place and track lab orders. No card is charged — usage is billed to you monthly at wholesale. Every order includes a fixed $15.00 Lab Processing Fee on top of the panel’s wholesale price.

POST/v1/orders

Place an order for a patient. Requires a consents object — the patient must have agreed to the Telehealth Consent and HIPAA Authorization (present both in your UI; links below). Send an Idempotency-Key header so retries never double-order. Use panel_id (standard) or custom_panel_id. NY, NJ and RI: Quest Diagnostics and Labcorp panels can't be ordered for patients with an address in those states — the request 400s before billing; check lab_name on GET /v1/panels.

Request

curl https://api.panelroute.com/v1/orders \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8ea40aa2-7727-4172-82e8-a58715fa9b6d" \
  -d '{
    "provider_id": "e3eb2bdf-…",
    "patient_id": "cb9f568d-…",
    "panel_id": "31407505-…",
    "consents": { "telehealth": true, "hipaa_authorization": true }
  }'

Response

{
  "order": {
    "id": "83914c97-…", "status": "processing",
    "amount_charged": "54.00", "collection_method": "walk_in_test"
  }
}
GET/v1/orders

List orders. Filter by ?provider_id= and ?status=.

Request

curl "https://api.panelroute.com/v1/orders?provider_id=e3eb2bdf-…" \
  -H "Authorization: Bearer prk_live_…"

Response

{ "data": [ { "id": "83914c97-…", "status": "processing", … } ],
  "has_more": false, "next_cursor": null }
GET/v1/orders/{id}

Retrieve one order, with patient + panel summary and current status.

Request

curl https://api.panelroute.com/v1/orders/83914c97-… -H "Authorization: Bearer prk_live_…"

Response

{
  "order": {
    "id": "83914c97-…", "status": "processing", "lab_status": "requisition_created",
    "amount_charged": "54.00", "result_available": false, "requisition_available": true,
    "patient": { "id": "cb9f568d-…", "first_name": "Jane", "last_name": "Doe" },
    "panel": { "id": "31407505-…", "type": "custom", "name": "TRT Panel (Male)" }
  }
}
GET/v1/orders/{id}/results

Fetch structured results once available. Returns 409 until the lab reports back (watch the order.results_ready webhook).

Request

curl https://api.panelroute.com/v1/orders/83914c97-…/results -H "Authorization: Bearer prk_live_…"

Response

{ "order_id": "83914c97-…", "results": { … }, "pdf_url": "https://…" }
GET/v1/orders/{id}/requisition.pdf

Download the requisition — the lab order form the patient takes to the draw site — as a PDF stream. Ready once requisition_available is true on the order (usually within minutes of placement); 409 before that. Share it with the patient: PanelRoute does not contact patients directly. Test orders return a canned sandbox PDF.

Request

curl https://api.panelroute.com/v1/orders/83914c97-…/requisition.pdf \
  -H "Authorization: Bearer prk_live_…" -o requisition.pdf

Response

%PDF-1.4 … (binary application/pdf)
GET/v1/orders/{id}/results.pdf

Download the signed lab report as a PDF stream. 409 until results are ready. Test orders return a canned sandbox PDF.

Request

curl https://api.panelroute.com/v1/orders/83914c97-…/results.pdf \
  -H "Authorization: Bearer prk_live_…" -o results.pdf

Response

%PDF-1.4 … (binary application/pdf)
POST/v1/orders/{id}/cancel

Cancel a submitted/processing order. Live orders can self-cancel within 1 hour of placement (after that, contact support); test orders any time. Charges/usage are reversed and order.cancelled fires.

Request

curl -X POST https://api.panelroute.com/v1/orders/83914c97-…/cancel \
  -H "Authorization: Bearer prk_live_…"

Response

{ "order_id": "83914c97-…", "status": "cancelled", "cleanup": "complete" }
GET/v1/orders/{id}/events

The order’s full status timeline — every transition in sequence. Reconcile against this instead of reconstructing state from webhooks.

Request

curl https://api.panelroute.com/v1/orders/83914c97-…/events -H "Authorization: Bearer prk_live_…"

Response

{ "order_id": "83914c97-…", "current_status": "delivered",
  "events": [
    { "from_status": null, "to_status": "pending_payment", "occurred_at": "…" },
    { "from_status": "pending_payment", "to_status": "processing", "occurred_at": "…" }
  ] }
POST/v1/orders/{id}/simulate

SANDBOX ONLY (test key). Advance a test order through its lifecycle — or rehearse the unhappy paths with to: "failed" / "cancelled". interpretation ("normal" | "abnormal", default abnormal) shapes the canned results. On "delivered", results attach and order.results_ready fires.

Request

curl https://api.panelroute.com/v1/orders/{id}/simulate \
  -H "Authorization: Bearer prk_test_…" \
  -H "Content-Type: application/json" \
  -d '{ "to": "delivered", "interpretation": "normal" }'   # or {} to advance one step

Response

{ "order_id": "…", "status": "delivered", "result_available": true }

Usage

Your billing ledger and monthly invoices. total_wholesale is what you owe for the period.

GET/v1/usage

Your usage and total. Scope to a billing period with ?period=YYYY-MM.

Request

curl "https://api.panelroute.com/v1/usage?period=2026-07" -H "Authorization: Bearer prk_live_…"

Response

{
  "period": "2026-07", "total_wholesale": "324.00", "order_count": 6,
  "events": [
    { "order_id": "83914c97-…", "wholesale": "54.00", "retail": "70.20",
      "billing_period": "2026-07", "occurred_at": "2026-07-01T17:45:55Z" }
  ]
}
GET/v1/invoices

Monthly wholesale invoices, newest first. Each period closes on the 1st and fires invoice.finalized.

Request

curl https://api.panelroute.com/v1/invoices -H "Authorization: Bearer prk_live_…"

Response

{ "data": [ { "id": "0b2f6c…", "billing_period": "2026-06",
    "total_wholesale": "324.00", "order_count": 6, "status": "finalized" } ],
  "has_more": false, "next_cursor": null }
GET/v1/invoices/{id}

One invoice with its line items (the usage rows it covers).

Request

curl https://api.panelroute.com/v1/invoices/0b2f6c… -H "Authorization: Bearer prk_live_…"

Response

{ "invoice": { "id": "0b2f6c…", "billing_period": "2026-06",
    "total_wholesale": "324.00", "status": "finalized",
    "lines": [ { "order_id": "83914c97-…", "wholesale": "54.00",
                 "reversed_after_close": false } ] } }

Webhook endpoints

Manage webhook endpoints programmatically — same catalog of events as the webhook portal. Omit event_types to subscribe to every event type your key can receive. The URL isn’t contacted at registration — deliveries start when events fire.

GET/v1/webhooks/endpoints

List your configured endpoints.

Request

curl https://api.panelroute.com/v1/webhooks/endpoints -H "Authorization: Bearer prk_live_…"

Response

{ "data": [ { "id": "ep_2x…", "url": "https://example.com/webhooks/panelroute",
    "event_types": null, "disabled": false } ] }
POST/v1/webhooks/endpoints

Add an HTTPS endpoint. The signing secret is returned once here (also via /{id}/secret) — use it to verify signatures.

Request

curl https://api.panelroute.com/v1/webhooks/endpoints \
  -H "Authorization: Bearer prk_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/panelroute",
        "event_types": ["order.results_ready", "order.failed"] }'

Response

{ "endpoint": { "id": "ep_2x…", "url": "https://example.com/webhooks/panelroute",
    "event_types": ["order.results_ready", "order.failed"] },
  "secret": "whsec_…" }
DELETE/v1/webhooks/endpoints/{id}

Remove an endpoint.

Request

curl -X DELETE https://api.panelroute.com/v1/webhooks/endpoints/ep_2x… \
  -H "Authorization: Bearer prk_live_…"

Response

{ "deleted": true }

Webhooks

Register HTTPS endpoints with POST /v1/webhooks/endpoints (below) to receive signed events as your orders progress — no need to poll. Verify each delivery with the endpoint's signing secret (standard webhook-id / webhook-timestamp / webhook-signature headers). Event types:

  • order.created — an order was accepted
  • order.status_changed — the order moved to a new status
  • order.results_ready — results are available to fetch
  • order.failed — the order failed at the lab (status lab_error, charge reversed) or payment failed (payment_failed)
  • order.cancelled — the order was cancelled
{
  "id": "msg_…", "type": "order.results_ready", "created_at": "…",
  "data": {
    "order_id": "83914c97-…", "status": "resulted",
    "result_available": true, "provider_id": "e3eb2bdf-…"
  }
}