RPh Labs Partner API
v1 REST · JSON

RPh Labs · Partner API v1

Order RPh Labs tests for your customers, and follow each one to results.

The Partner API lets an approved business read our live test catalog, place orders on behalf of its customers, and track every order through physician review, kit shipping, sample receipt and lab results. Every request needs an API key that RPh Labs issues to you.

Base URL https://rphlabs.com/wp-json/rph-labs/partner/v1

How payment works

Your customer pays RPh Labs directly. When you create an order it starts in awaiting_payment and the response includes a secure payment link. Send that link to your customer, or ask us to email it for you. Once the order is paid it runs through our standard pipeline: physician review by our medical partner, kit shipping, sample testing and results. No card data ever passes through the API.

After payment we create an RPh Labs account for the patient (if they do not already have one) and email them a link to set a password. Their results appear under My Account › My Reports.

Quick start

  1. Get your API key. RPh Labs creates your partner profile and sends you a key that starts with rphk_. See Authentication.
  2. Check the key with GET /ping.
  3. Load the catalog with GET /tests and keep each test's id.
  4. Create an order with POST /orders, passing your own external_id.
  5. Send the customer the payment.url from the response.
  6. Follow progress by checking GET /orders?updated_after=… every few minutes. See Tracking order changes.
curl https://rphlabs.com/wp-json/rph-labs/partner/v1/ping \
  -H "X-RPH-Api-Key: $RPH_API_KEY"

Authentication

Every endpoint requires an API key. Keys are created by RPh Labs staff and tied to your partner profile. There is no self-serve signup and no way to call the API without a key.

Key format

A key looks like rphk_3f9a1c07e2_9b4e…: the prefix rphk_, your 10-character partner ID, an underscore, and 48 random hex characters. Anything else is rejected before it is checked.

Sending the key

Send it in the X-RPH-Api-Key header, or as a bearer token. Both work the same way. Requests must use HTTPS. Never send a key to an http:// address, even once: the key leaves your server before any redirect can protect it.

Headers
X-RPH-Api-Key: rphk_3f9a1c07e2_9b4e…
# or
Authorization: Bearer rphk_3f9a1c07e2_9b4e…

How keys are handled

  • Shown once. We see your key only at the moment it is created. RPh Labs stores a SHA-256 hash, never the key itself, so we cannot look it up for you later.
  • One active key per partner. If a key is lost or exposed, ask us for a new one. The old key stops working the moment the new one is issued.
  • Revocable. RPh Labs can disable your profile at any time. A disabled profile's key is refused.
  • Server-side only. Keep the key on your servers. Never put it in a browser, a mobile app or a public repository.
  • Valid for one year. A key expires 365 days after it is issued. Every response carries an X-RPH-Key-Expires header and GET /ping returns key_expires_at; ask us for a new key before that date. An expired key returns 401 key_expired.
  • Headers only, never the URL. A request with the key anywhere in the query string is refused with 400 key_in_url and counted as a failed attempt, because URLs end up in proxy and server logs. If a key has been sent that way, ask us to rotate it.

Requesting access. Send RPh Labs your company name, a technical contact email, and the public IP addresses of the servers that will call the API. If you need result PDFs through the API, we will also ask for a signed Business Associate Agreement. Your key is delivered through a secure channel, never by ordinary email.

Access controls

  • Server-to-server only. Requests that carry an Origin header (which every browser sends on cross-site calls) are refused, and the API sends no CORS headers.
  • IP allowlist. Give us the public IP addresses or CIDR ranges of the servers that will call the API. Once they are on file, your key is refused from any other address.
  • Lockout. An IP address that sends 20 missing or invalid keys is blocked for 15 minutes after its last failure.
  • Audit trail. Every request is logged with the time, your partner ID, the calling IP, the endpoint, the order it touched and the result. Request bodies and patient details are never written to the log.
  • Request size. Bodies over 32 KB are refused with 413 payload_too_large. A complete order is under 2 KB.
  • No discovery. The REST index and route listings on rphlabs.com are closed to anonymous callers. Only the endpoints documented here answer to your key; anything else returns 401.

Rate limit

Each partner can make 120 requests per clock minute. Request number 121 in the same minute returns 429 rate_limited. Wait for the next minute and retry, ideally with backoff. Checking for changes every few minutes uses a tiny fraction of this.

Conventions

TopicRule
FormatRequests and responses are JSON (UTF-8). Send Content-Type: application/json on POST.
DatesISO 8601 in UTC, for example 2026-10-16T14:05:00Z. Dates of birth are YYYY-MM-DD.
MoneyDecimal strings with two places, such as "299.00", plus a currency code (USD).
IDsTests and orders use RPh Labs numeric IDs. Your own reference for an order is its external_id.
Paginationpage and per_page parameters; totals in the body and in X-WP-Total / X-WP-TotalPages headers.
ScopeYou only ever see orders created with your own key. Another partner's order ID returns 404.
CoverageUS addresses only. Kits cannot ship to CA, MD, NY, PA or RI.

Errors

Errors return a non-2xx status and a JSON body with a stable code you can branch on, a readable message, and data.status. Validation errors add data.fields, keyed by field path.

422 example
{
  "code": "invalid_order",
  "message": "Some fields are missing or invalid.",
  "data": {
    "status": 422,
    "fields": {
      "patient.dob": "The patient must be 18 or older.",
      "shipping_address.state": "Kits cannot ship to NY. Not available in: NY, MD, PA, RI, CA."
    }
  }
}
StatusCodeMeaning
400invalid_jsonThe body is not a JSON object, or the content type is wrong.
401missing_api_keyNo key was sent, or it is not in the rphk_ format.
401invalid_api_keyThe key is unknown, was replaced, or the partner is disabled.
403baa_requiredResult PDFs need a Business Associate Agreement on file.
404test_not_foundNo test with that ID is available to partners.
404order_not_foundNo order with that ID belongs to your account.
404result_not_foundNo ready result with that ID is on this order.
409not_eligibleThe patient cannot order this test right now (limit one per person per year).
409cannot_cancelOnly unpaid orders can be cancelled through the API.
422invalid_orderOne or more fields failed validation. See data.fields.
403https_requiredThe request did not use HTTPS.
403browser_not_allowedThe request carried an Origin header, so it came from a browser. Call the API from your server.
403ip_not_allowedThe key is valid but your IP address is not on your allowlist.
429rate_limitedMore than 120 requests in the current minute.
429too_many_failuresYour IP address sent 20 bad or missing keys in a row. It is blocked for 15 minutes after the last failure.
400key_in_urlThe API key was sent in the URL. Send it in a header, and ask us to rotate it.
401key_expiredThe key is more than a year old. Ask RPh Labs for a new one.
413payload_too_largeThe request body is over 32 KB.
500order_failedThe order could not be created. Retrying with the same external_id is safe.

Order lifecycle

Each order has one partner-facing status. A typical order moves through these in order:

  1. awaiting_paymentCreated. Waiting for the customer to use the payment link.
  2. paidPaid. Physician review starts once the patient finishes the intake.
  3. kit_shippedThe collection kit has a USPS label and tracking number.
  4. sample_receivedThe returned sample arrived and is being tested.
  5. results_readyLab results are uploaded and visible to the patient.
Other statusesMeaning
payment_failedA payment attempt failed. The same payment link can be used again.
on_holdPaused by RPh Labs staff, for example while a question is resolved.
completedClosed by RPh Labs without lab results attached to it.
cancelledCancelled by you, by RPh Labs, or automatically after 30 days unpaid.
refundedRefunded by RPh Labs.

Physician review

Every test requires review by a licensed physician through our medical partner. After paying, the patient completes a short medical intake. Until they do, physician_review.status is awaiting_patient_intake and physician_review.intake_url holds the link to send them. Answers you pass in intake when creating the order are pre-filled for the patient.

physician_review.statusMeaning
not_startedThe order is not paid yet.
awaiting_patient_intakePaid, and waiting for the patient to finish the intake at intake_url.
in_reviewThe intake is in and a physician is reviewing it.
approvedThe physician approved the order.
cancelledThe review case was cancelled.

Check your key

GET/ping

Confirms the key works and shows what your profile is enabled for. Use it as a health check.

200 Response
{
  "ok": true,
  "partner": "Acme Wellness",
  "results_pdfs": false,
  "key_issued_at": "2026-10-16T14:05:00Z",
  "key_expires_at": "2027-10-16T14:05:00Z",
  "time": "2026-10-18T09:30:00Z"
}
FieldDescription
partnerYour partner name as RPh Labs has it on file.
results_pdfstrue when a BAA is on file and result PDFs are available to you.
key_issued_at, key_expires_atWhen your current key was issued and when it stops working (one year later). Alert yourselves a month ahead and ask for a new key. The same expiry is in the X-RPH-Key-Expires header on every response.
timeServer time. Use it to set updated_after from our clock instead of yours.

List tests

GET/tests

Returns every test you can order, live from our catalog, in the same order as our shop. Prices are the current customer prices. Cache the list and refresh it with updated_after.

Query parameters

ParameterDescription
updated_after optionalISO 8601 date. Only tests changed after this time are returned.
200 Response
{
  "tests": [
    {
      "id": 71,
      "sku": "RPH-PGX-299",
      "name": "CLIA-Accredited Pharmacogenetic Testing Kit",
      "slug": "pharmacogenetic-testing-kit",
      "url": "https://rphlabs.com/pharmacogenetic-testing-kit/",
      "price": "299.00",
      "regular_price": "299.00",
      "sale_price": null,
      "currency": "USD",
      "in_stock": true,
      "short_description": "At-home pharmacogenetic testing kit: one cheek swab shows how your genes are likely to affect 240+ medications.",
      "description": "…",
      "images": ["https://rphlabs.com/wp-content/uploads/2026/09/kit-box-1.webp"],
      "categories": ["CGx & PGX"],
      "requires_physician_review": true,
      "limit_per_person": { "quantity": 1, "days": 365 },
      "updated_at": "2026-10-12T09:30:00Z"
    }
  ],
  "count": 1
}

Test fields

FieldDescription
idPass this as test_id when creating an order.
skuOur product code, for your records. Orders use id.
priceWhat the customer pays now. sale_price is null when nothing is on sale.
short_description, descriptionPlain text with HTML removed, ready to show in your own UI.
imagesFull-size image URLs, main image first.
requires_physician_reviewAlways true: every test is reviewed by a physician.
limit_per_personFor test kits, one per person every 365 days. null when there is no limit.

Get a test

GET/tests/{id}

Returns one test in the same shape as an item in tests. Returns 404 test_not_found if the test is unpublished, hidden or has no price.

Create an order

POST/orders

Creates an order for one test for one patient. It starts in awaiting_payment; the response's payment.url is the link your customer uses to pay. Returns 201 Created.

Safe to retry. external_id is your own unique reference. If you send an external_id you have already used, no new order is created: you get the existing order back with 200 OK and the header X-RPH-Idempotent-Replay: true. The rest of the body is ignored on a replay, so use a new external_id for a genuinely new order.

Body

FieldRules
external_id requiredYour reference for this order. Up to 64 characters, unique within your account.
test_id requiredA test id from GET /tests.
patient.first_name requiredUp to 60 characters.
patient.last_name requiredUp to 60 characters.
patient.email requiredA valid address. The payment link, account invite and kit updates go here.
patient.phone requiredA 10-digit US number. Punctuation and a leading 1 are removed.
patient.dob requiredYYYY-MM-DD. The patient must be 18 or older.
patient.sex requiredfemale or male (sex assigned at birth). Used by the physician intake.
shipping_address requiredWhere the kit goes. All six fields are required: address_1, address_2 (send "" if there is no apartment or suite), city, state (2-letter code), postcode (5-digit ZIP or ZIP+4) and country ("US"). Not CA, MD, NY, PA or RI.
billing_address requiredThe patient's billing address, with the same six fields. Send the shipping address again if they are the same.
intake.current_medications requiredMedications the patient takes now. An array of strings, or one newline-separated string, with at least one item and up to 50. Send ["None"] if there are none. Pre-fills the physician intake.
intake.considering_medications requiredMedications the patient is considering. Same format, ["None"] if none.
intake.allergies requiredKnown allergies. Same format, ["None"] if none.
intake.conditions requiredMedical conditions. Same format, ["None"] if none.
notes requiredA string up to 1,000 characters, saved as a note on the order for RPh Labs staff. Send "" if you have nothing to add.
send_payment_email requiredA JSON boolean. true to have RPh Labs email the payment link to the patient; false if you will send payment.url yourself. Strings such as "yes" are rejected.
curl -X POST https://rphlabs.com/wp-json/rph-labs/partner/v1/orders \
  -H "X-RPH-Api-Key: $RPH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "ACME-100245",
    "test_id": 71,
    "patient": {
      "first_name": "Dana",
      "last_name": "Rivera",
      "email": "dana.rivera@example.com",
      "phone": "(305) 555-0142",
      "dob": "1984-03-09",
      "sex": "female"
    },
    "shipping_address": {
      "address_1": "1200 Brickell Ave",
      "address_2": "Apt 1504",
      "city": "Miami",
      "state": "FL",
      "postcode": "33131",
      "country": "US"
    },
    "billing_address": {
      "address_1": "1200 Brickell Ave",
      "address_2": "Apt 1504",
      "city": "Miami",
      "state": "FL",
      "postcode": "33131",
      "country": "US"
    },
    "intake": {
      "current_medications": ["Sertraline 50 mg", "Atorvastatin 20 mg"],
      "considering_medications": ["Bupropion"],
      "allergies": ["Penicillin"],
      "conditions": ["None"]
    },
    "notes": "",
    "send_payment_email": true
  }'

The response is the full order object.

Order-specific errors

  • 422 invalid_order: a field failed validation; data.fields lists each one.
  • 409 not_eligible: this patient email already ordered this kit within the last year.
  • 400 invalid_json: the body is missing or is not a JSON object.

List orders

GET/orders

Your orders, newest first. To pick up changes, pass updated_after set to the time of your last check. See Tracking order changes.

Query parameters

ParameterDescription
page optionalPage number, from 1. Default 1.
per_page optional1 to 100. Default 20.
external_id optionalReturn only the order with this reference of yours.
updated_after optionalISO 8601 date. Only orders changed after this time.
200 Response
{
  "orders": [ /* order objects */ ],
  "page": 1,
  "per_page": 20,
  "total": 57,
  "total_pages": 3
}

Get an order

GET/orders/{id}

Returns one order object. Use it to refresh a single order, for example right after a customer tells you they have paid.

Cancel an order

POST/orders/{id}/cancel

Cancels an order that is still awaiting_payment or payment_failed, so its payment link stops working. No body is needed. Returns the updated order. A paid order returns 409 cannot_cancel; contact RPh Labs to cancel or refund it.

Download a result PDF

GET/orders/{id}/results/{rid}

BAA partners only. Results are protected health information. This endpoint, and the results list on the order, are only available once a signed Business Associate Agreement is on file. Without one it returns 403 baa_required.

Returns the PDF itself (Content-Type: application/pdf) as an attachment. Take {rid} from results[].id on the order. You can also follow results[].download_url directly with your key. Every download is recorded on the order.

cURL
curl https://rphlabs.com/wp-json/rph-labs/partner/v1/orders/10452/results/11873 \
  -H "X-RPH-Api-Key: $RPH_API_KEY" \
  -o RPH-10452-results.pdf

The order object

Returned by every order endpoint. It contains the patient's personal details, so store and log it with the same care as any patient record.

Order
{
  "id": 10452,
  "number": "10452",
  "external_id": "ACME-100245",
  "status": "kit_shipped",
  "created_at": "2026-10-16T14:05:00Z",
  "updated_at": "2026-10-17T16:40:12Z",
  "paid_at": "2026-10-16T15:22:48Z",
  "payment": {
    "required": false,
    "url": null,
    "total": "299.00",
    "currency": "USD"
  },
  "items": [
    { "test_id": 71, "sku": "RPH-PGX-299", "name": "CLIA-Accredited Pharmacogenetic Testing Kit", "quantity": 1, "total": "299.00" }
  ],
  "patient": {
    "first_name": "Dana", "last_name": "Rivera",
    "email": "dana.rivera@example.com", "phone": "3055550142", "dob": "1984-03-09"
  },
  "shipping_address": {
    "address_1": "1200 Brickell Ave", "address_2": "Apt 1504",
    "city": "Miami", "state": "FL", "postcode": "33131", "country": "US"
  },
  "physician_review": {
    "status": "approved",
    "intake_completed": true,
    "intake_url": null
  },
  "shipments": [
    {
      "kind": "outbound", "carrier": "USPS", "service": "Ground Advantage",
      "tracking_number": "9400111899223197428490",
      "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223197428490",
      "created_at": "2026-10-17T16:40:00Z"
    }
  ],
  "sample": { "received": false, "received_at": null, "return_tracking_status": null },
  "results_available": 0,
  "results": null
}
FieldDescription
idRPh Labs order ID, used in every order URL.
numberThe order number shown to the patient and to RPh Labs support.
statusOne of the lifecycle statuses.
paid_atnull until the customer pays.
payment.requiredtrue while the order is unpaid; payment.url is then the link to pay, otherwise null.
physician_reviewReview state, whether the intake is done, and intake_url while the paid patient still needs to complete it.
shipments[]USPS labels. kind is outbound (kit to the patient) or return (prepaid return of the sample).
sampleWhether the lab has the sample, when it arrived, and the latest return-tracking status.
results_availableNumber of lab result reports ready. Shown to every partner.
resultsnull unless a BAA is on file. Then a list of { id, type, name, uploaded_at, download_url }, where type is lab_results or prescription.

Tracking order changes

The Partner API has no webhooks. You follow your orders by asking for the ones that changed since you last checked: call GET /orders with updated_after set to the time of your previous check. Every order you get back is complete, so there is nothing else to fetch.

What moves an order's updated_at

Any change you can see in the order object:

ChangeWhere it shows
Payment, cancellation, refund or holdstatus, paid_at, payment
Physician review started, approved or cancelledphysician_review.status
Kit or return label createdshipments[], and status becomes kit_shipped
Sample arrived at the labsample.received, and status becomes sample_received
Lab results uploadedresults_available, results[] (BAA partners), and status becomes results_ready

A reliable sync loop

  1. Every 5 to 15 minutes, call GET /orders?updated_after=<last_sync>&per_page=100.
  2. Read every page: keep going while page is less than total_pages.
  3. For each order, compare it with your copy by id (or your external_id) and act on what changed.
  4. Save the time you started this run as the next last_sync, minus one minute of overlap so nothing saved during the run is missed. Overlap only means you see a few orders twice.

Use the time value from GET /ping if your server clock may drift. A poll every five minutes is about 0.01% of your rate limit.

curl -G https://rphlabs.com/wp-json/rph-labs/partner/v1/orders \
  -H "X-RPH-Api-Key: $RPH_API_KEY" \
  --data-urlencode "updated_after=2026-10-16T14:00:00Z" \
  --data-urlencode "per_page=100" \
  --data-urlencode "page=1"

Security and HIPAA

  • Treat order responses as patient records. They include names, contact details, dates of birth and addresses. Encrypt them at rest, limit who can read them, and keep them out of general-purpose logs.
  • Results need a BAA. Result PDFs and the results list are only available once a signed Business Associate Agreement is on file. Every download is recorded on the order.
  • Nothing is pushed to you. RPh Labs never sends patient data to your systems. You request it, over an authenticated connection, from servers you have allowlisted.
  • Get consent. Before you send a customer's details to RPh Labs, make sure they have agreed to share them with us for testing.
  • Protect the key. Store it in a secrets manager or environment variable, call the API only from your servers, and ask us to rotate it if anyone who should not have it may have seen it.
  • HTTPS only. The API only works over TLS, and every response sends Strict-Transport-Security.
  • Nothing is cached. Every response sends Cache-Control: no-store, so browsers, proxies and CDNs do not keep copies of patient data. Don't cache order responses on your side longer than you need them.
  • Every access is logged. RPh Labs keeps an append-only audit log of API requests and of every change to your partner profile (key issued or rotated, BAA status, allowlist), for HIPAA access review.
  • Restrict by IP. Ask us to put your server addresses on your allowlist before you go live, so a leaked key is useless anywhere else.
  • Rotate yearly. Keys expire 365 days after they are issued. Watch X-RPH-Key-Expires (or key_expires_at from GET /ping) and ask for a replacement before then; the old key stops the moment the new one is issued.
  • Keys stay out of URLs and logs. The API refuses a key sent in the query string. On your side, keep the key out of request logs, error reports and crash dumps.
  • Nothing to discover. The site's REST index and route listings are closed to anonymous callers, request bodies are capped at 32 KB, and every API response is marked noindex and nosniff.

Changelog

DateChange
2026-10-18Lockdown: keys expire one year after issue (401 key_expired; X-RPH-Key-Expires header and key_issued_at / key_expires_at on GET /ping); a key in the URL is refused (400 key_in_url); request bodies capped at 32 KB (413 payload_too_large); the site's REST index and route listings closed to anonymous callers.
2026-10-16Security hardening: IP allowlists, failed-key lockout, browser requests refused, no-store and HSTS headers, audit log. On POST /orders, every body field is now required: patient.sex, all four intake lists, billing_address, address_2 and country on both addresses, notes and send_payment_email. address_2 and notes may be "". sku is no longer accepted in place of test_id.
2026-10-16Version 1: catalog, orders, cancellation, result PDFs and change tracking through updated_after.

Questions, or ready to integrate? Contact RPh Labs to request your API key.