Read the prescribable catalog, track orders, run pharmacy fulfillment, manage pharmacy products and lots, read lab listings, and receive signed webhooks over a REST API. Version 2026-10-29.
The PEPTPlus API serves three audiences: provider practices read the prescribable catalog and track their orders, pharmacies fulfill routed orders and manage their catalog (products, lots, certificates of analysis), and labs read their API listings and licensees. Every organization can register webhook endpoints.
| Environment | Base URL | Keys | Status |
|---|---|---|---|
| Production | https://pept.plus/api/v1 | sk_*_live_ | Live |
| Production, dedicated host | https://api.pept.plus/v1 | sk_*_live_ | Planned until the host is bound |
| Sandbox (synthetic data) | https://demo.pept.plus/api/v1 | sk_*_test_ | Live. Test keys are minted in the demo portal on request; sandbox.api.pept.plus will alias this host |
HTTPS only, JSON in and out. The API is versioned by URI (/v1) and dated by the X-API-Version response header (currently 2026-10-29). Lists are wrapped as { "object": "list", "data": [...], "has_more": false }. Object ids are opaque and prefixed: ord_ order, cmp_ compound, prd_ product, bat_ lot, coa_ certificate, lst_ listing, lic_ license, org_ organization, pt_ patient token, whe_ webhook endpoint, whd_ delivery, evt_ event. Any id may be sent back prefixed or as the bare UUID.
Machine readable reference: OpenAPI 3.1 (YAML), the same as JSON, and a Postman collection with one request per endpoint.
pt_ tokens, never as names, dates of birth or addresses. Live keys require a Business Associate Agreement on file (see Authentication). Send only the minimum necessary.Send your API key as a bearer token on every request. Keys are minted, labeled and revoked on the portal account page (Provider, Pharmacy or Lab, then API keys). The full key is shown once at creation; only a hash is stored.
sk_*_live_) are minted only in the production portal, and only when the organization is active and has a Business Associate Agreement on file (the minting dialog records it). A pharmacy also needs a plan; the base plan is free, so no pharmacy is blocked on price.sk_*_test_) are sandbox keys. The demo portal mints test keys only, whatever was asked for, against synthetic data.401 authentication_error.Scopes are fixed per organization type and assigned at minting; they are not chosen per key. A key lacking the endpoint's scope gets 403 permission_error with the missing scope in required. A key whose organization type cannot call an endpoint also gets 403 permission_error.
| Organization | Scopes on every key |
|---|---|
Provider practice (sk_prov_) | orders:read catalog:read webhooks:manage |
Pharmacy (sk_pharm_) | pharmacy:fulfill pharmacy:catalog orders:read catalog:read webhooks:manage |
Lab (sk_lab_) | listings:read licensees:read webhooks:manage |
Every key is tenant scoped by row level security: you only ever see your own organization's data. Writes and patient-bearing reads are recorded in the audit log with the key id and request id. There is no prescribe:write scope today Deferred; see Roadmap.
| Header | Direction | Meaning |
|---|---|---|
Authorization | request | Bearer sk_..., required on every route. |
X-Request-Id | both | Optional on the request (1 to 128 characters from letters, digits, . _ : -); echoed back when present, otherwise a fresh UUID. Also inside every error envelope. Quote it when you contact support. |
Idempotency-Key | request | Optional on writes, 1 to 255 characters. See Idempotency. |
X-API-Version | response | The contract date this response follows: 2026-10-29. |
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset | response | Requests allowed per 60 second window for this key, requests left, and the unix time the window ends. Present on every authenticated response. |
Retry-After | response | Seconds to wait, on 429 only. |
Idempotent-Replayed | response | true when the body is the stored response for your Idempotency-Key. |
Deprecation, Sunset, Link | response | Only on a deprecated route: RFC 9745 Deprecation: @<unix>, RFC 8594 Sunset date, and a Link rel="deprecation" to the replacement. No route is deprecated today; the mechanism ships first so you can watch for it. |
Every error, including an unknown path, is one JSON envelope with a stable type, a human message and the request_id. Some types add fields: required (the missing scope), details (field errors), retry_after (seconds).
| HTTP | type | When |
|---|---|---|
| 401 | authentication_error | Missing, malformed, unknown, revoked or wrong-environment key; organization not active. |
| 403 | permission_error | Key lacks the scope (required names it) or its organization type cannot call the route. |
| 404 | not_found | No such object for your organization, or no such API route. |
| 422 | validation_error | Bad body or query parameter; details carries field errors when a schema failed. |
| 422 | idempotency_error | The Idempotency-Key was already used with a different request body. |
| 429 | rate_limit_error | Per-key limit exceeded, or too many failed authentications from one address. |
| 500 | internal_error | Unexpected failure. Safe to retry with the same Idempotency-Key. |
Unknown paths under /api/v1 answer 404 not_found in this envelope with the standard headers, never an HTML page:
Each key may make 600 requests per 60 second window by default, counted across all servers. The window is fixed, not sliding; X-RateLimit-Reset says when it ends. Over the limit the API answers 429 rate_limit_error with Retry-After and retry_after in the body. A different limit can be set per key on request. Failed authentications are throttled separately: more than 60 in a minute from one address also returns 429.
List routes take limit (1 to 100, default 25; larger values are clamped to 100, zero or non-integers return 422) and starting_after, the id of the last item of the previous page. Pages are ordered newest first by (created_at, id), so a cursor stays stable while rows are inserted. Keep requesting while has_more is true. A cursor id that your key cannot see returns 422.
Two routes are not paginated and ignore these parameters: GET /v1/listings/{id}/licensees (returns the full list without has_more) and GET /v1/webhooks (at most 10 endpoints, has_more always false).
Send an Idempotency-Key (1 to 255 characters, a UUID works well) on any write you might retry. The gateway hashes the method, path and raw body. The first request runs and its status and JSON body are stored for 24 hours, scoped to your key. The same key with the same request within that window returns the stored response unchanged with Idempotent-Replayed: true; the handler does not run again. The same key with a different body returns 422 idempotency_error. Responses of 500 and above are not stored, so a failed attempt can be retried with the same key.
Honored on every POST, PATCH and DELETE except two: the certificate of analysis upload and the webhook test send, which ignore the header.
/v1) changes only for a breaking redesign. Within v1, changes are additive: new fields, new routes, new event types and new enum values may appear at any time, so parse leniently.X-API-Version. The date advances when behaviour changes in a way worth reading about; the changelog below lists each date.Deprecation, Sunset and Link headers at least 12 months before removal.2026-10-29. Request ids, the single error envelope, DB-backed per-key rate limits, cursor pagination with has_more, Idempotency-Key, API version and deprecation headers, JSON 404 for unknown paths, org status and BAA gate on live keys, pharmacy catalog routes (products, lots, COA upload), webhook management routes and signed deliveries, OpenAPI and Postman files. Breaking: order list default limit 50 to 25 and maximum 200 to 100; prescribe:write removed from provider keys; error codes renamed (unauthorized to authentication_error, insufficient_scope to permission_error, rate_limited to rate_limit_error) and nested under error; unknown paths return JSON instead of HTML.
Compounds your organization can prescribe (providers) or list (pharmacies), with their strengths, routes and eligibility basis. Only substances marked prescribable in the eligibility register are returned. Scope catalog:read; provider and pharmacy keys.
/v1/catalog?limit=&starting_after=catalog:readPage of compounds/v1/catalog/{id}catalog:readOne compound| Field | Type | Notes |
|---|---|---|
id | string | cmp_ id. Use it as compound when creating a pharmacy product. |
name | string | Display name. |
eligibility_basis | string or null | Why the compound is prescribable, for example 503A. |
strengths | string[] | Human readable strengths available. |
routes | string[] | Delivery routes available (subq, im, iv, topical, edible). |
doses | object[] | Each { value, unit }; unit is one of mg mcg g IU mL mg/mL mcg/mL IU/mL mEq %. |
Returns the compound object, or 404 not_found when the id is unknown or not prescribable for you.
Orders visible to your organization. A provider key sees orders for its own prescriptions; a pharmacy key sees orders routed to it. Scope orders:read. These reads are audited as patient-bearing reads (the list records a count only; the detail records the order id and patient token).
/v1/orders?status=&limit=&starting_after=orders:readPage of orders, newest first/v1/orders/{id}orders:readOne order with its latest shipment| Field | Type | Notes |
|---|---|---|
status | string | Optional filter: created, awaiting_payment, paid, sent_to_pharmacy, in_fulfillment, shipped, delivered, action_needed, canceled. Anything else returns 422. |
limit | integer | 1 to 100, default 25. |
starting_after | string | Cursor: the last id of the previous page. |
| Field | Type | Notes |
|---|---|---|
id | string | ord_ id. |
status | string | One of the statuses listed above. |
channel | string | How the order was created, for example web or api. |
patient | string or null | pt_ token. The only patient identifier the API returns. |
compound | string or null | Compound display name. |
strength | object or null | { value, unit }. |
route | string or null | Delivery route. |
amount | object | patient_paid_cents and platform_fee_cents as integer cents (or null), currency usd. The platform fee per prescription is $0. |
shipment | object or null | { carrier, tracking_no, status } for the latest shipment, null before one is recorded. |
created_at, updated_at | string | ISO 8601 UTC timestamps. |
Pull the orders routed to your pharmacy, acknowledge them, and report status and shipping. Scope pharmacy:fulfill; pharmacy keys only. Every response is the full order object shown under Orders. The three POST routes honor Idempotency-Key.
/v1/pharmacy/orders?status=&limit=&starting_after=pharmacy:fulfillYour fulfillment queue/v1/pharmacy/orders/{id}/acceptpharmacy:fulfillAcknowledge: status becomes in_fulfillment/v1/pharmacy/orders/{id}/statuspharmacy:fulfillSet in_fulfillment, shipped, delivered or action_needed/v1/pharmacy/orders/{id}/shipmentpharmacy:fulfillRecord carrier and tracking; status becomes shippedSame parameters and shape as GET /v1/orders (status filter, limit, starting_after, has_more), scoped to orders routed to your pharmacy.
Empty body. Moves the order to in_fulfillment and returns it. 404 when the order is not routed to your pharmacy.
| Field | Type | Notes |
|---|---|---|
status | string | Required. One of in_fulfillment, shipped, delivered, action_needed. Payment statuses cannot be set through the API. |
| Field | Type | Notes |
|---|---|---|
carrier | string | Required, 1 to 64 characters. |
tracking_no | string | Required, 1 to 128 characters. Stored with the shipment; never written to the audit log. |
422 validation_error and the reason in message. Acceptance through the API is recorded in the e-prescription transmission log with transport partner_api.Manage your own listings the way the portal does: products (one compound at one strength through one channel), lots with quantity and dates, and a certificate of analysis PDF per lot. Scope pharmacy:catalog; pharmacy keys only. Prices here are decimal dollar strings (products are priced in dollars in the portal); order amounts stay in cents.
/v1/pharmacy/products?channel=&status=&limit=&starting_after=pharmacy:catalogYour products/v1/pharmacy/productspharmacy:catalogCreate a product (pending review)/v1/pharmacy/products/{id}pharmacy:catalogOne product/v1/pharmacy/products/{id}pharmacy:catalogChange price, availability or form/v1/pharmacy/batches?product=&limit=&starting_after=pharmacy:catalogYour lots with latest COA/v1/pharmacy/batchespharmacy:catalogCreate a lot/v1/pharmacy/batches/{id}pharmacy:catalogOne lot/v1/pharmacy/batches/{id}/coapharmacy:catalogUpload a COA PDF (multipart or base64)Filters: channel is patient_rx (503A, patient prescriptions) or office_stock (503B, office stock); status is live, pending_review, draft, unlisted or hidden.
For office_stock products pricing is { "wholesale_price", "pack_size", "lead_time_days", "currency" } instead. status meanings: draft (saved, not submitted), pending_review (awaiting PEPTPlus verification), live (approved and visible), unlisted (approved, hidden by you), hidden (hidden by PEPTPlus).
| Field | Type | Notes |
|---|---|---|
compound | string | Required. cmp_ id from GET /v1/catalog. A compound not prescribable through the chosen channel returns 422. |
channel | string | patient_rx (default) or office_stock. |
delivery_method | string | Required. subq, im, iv, topical or edible. |
strength | object | Required. { value: number or decimal string, unit } with unit from the strength list. |
dosage_form_code, quantity_uom_code | string | Required. NCPDP dosage form and dispensing unit codes, 1 to 16 characters. |
patient_price, price_basis | string, string | patient_rx: price required unless published is false; price_basis per_rx or per_unit. |
pack_size, wholesale_price, lead_time_days | integer, string, integer | All three required for office_stock. |
published | boolean | false saves a draft that is not submitted for review. Default: submit for review. |
batch | object | Optional first lot: { lot_no, quantity?, expiry_date? }. |
Returns 201 with the product in pending_review (or draft). It goes live after PEPTPlus verification; subscribe to batch.verified to hear about it.
Send at least one of published, patient_price, price_basis, wholesale_price, pack_size, lead_time_days, delivery_method, dosage_form_code, quantity_uom_code. published: true shows an approved product in the marketplace, false unlists it. Changing delivery_method on an approved product sends it back for review; the response adds sent_to_review: true.
| Field | Type | Notes |
|---|---|---|
product | string | Required. prd_ id. |
lot_no | string | Required, 1 to 64 characters. |
quantity | integer | Optional, 0 or more. |
mfg_date, expiry_date | string | Optional. MM/YYYY, YYYY-MM or YYYY-MM-DD. |
Attach the COA PDF to a lot, at most 15 MB. Either multipart/form-data with a file field and optional purity_pct, lab_name, tested_on fields, or JSON with pdf_base64 and the same optional fields. Values are stored as sent; nothing is parsed from the PDF. Returns 201 with the lot and its new coa. This route ignores Idempotency-Key. If file storage is not configured on the host it answers 503 with a flat { "error": "storage_unavailable", "message" } body.
Read your lab's API listings and the pharmacies licensing them. Scopes listings:read and licensees:read; lab keys only.
/v1/listings?limit=&starting_after=listings:readYour listings with licensee counts/v1/listings/{id}/licenseeslicensees:readPharmacies licensing one listing| Field | Type | Notes |
|---|---|---|
id | string | lst_ id. |
substance | string | Canonical substance name. |
monthly_fee | string or null | Decimal dollars. |
certified | boolean | Whether PEPTPlus has certified the listing. |
eligibility_note | string or null | Free text from the eligibility register. |
status | string | active or coming. |
licensee_count | integer | Active licenses against this listing. |
The licensee list is not paginated: it returns every license on the listing and has no has_more. The pharmacy appears as an org_ id only; its name and other details are not visible to the lab. 404 not_found when the listing is not yours.
Register https endpoints and receive signed events instead of polling. Scope webhooks:manage; every organization type. Endpoints can also be managed in the portal (Pharmacy, Account, Webhooks; Provider, Ordering, Webhooks), where the delivery log and replay live.
/v1/webhookswebhooks:manageYour endpoints (at most 10)/v1/webhookswebhooks:manageRegister an endpoint; secret returned once/v1/webhooks/{id}webhooks:manageOne endpoint with its 20 latest deliveries/v1/webhooks/{id}webhooks:manageRemove an endpoint/v1/webhooks/{id}/testwebhooks:manageQueue a ping event| Field | Type | Notes |
|---|---|---|
url | string | Required. https on a public host, 12 to 2048 characters. No embedded credentials, loopback or private addresses. |
description | string or null | Optional, up to 200 characters. |
events | string[] | Required, 1 to 32 event types from the catalog below, or ["*"] for everything. Every endpoint also receives ping. |
secret (whsec_ plus 48 hex characters) appears in this 201 response only. Store it; it is never shown again and cannot be recovered from the database. The other calls:
The detail response adds recent_deliveries: up to 20 delivery objects, newest first, each with status (pending, delivered, dead), attempt_count, next_attempt_at, last_status_code, last_error, delivered_at and replay_of.
Returns 202 with the queued ping delivery. The dispatcher runs every 15 minutes, so poll the endpoint detail for the result. This route ignores Idempotency-Key.
Returns { "id": "whe_...", "object": "webhook_endpoint", "deleted": true }. Pending deliveries are dropped; the delivery log is kept.
Each event is a POST with Content-Type: application/json, User-Agent: PEPTPlus-Webhooks/1.0 and these headers:
| Header | Value |
|---|---|
PEPTPlus-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> keyed with your whsec_ secret |
PEPTPlus-Event | the event type, for example order.created |
PEPTPlus-Event-Id | evt_ id, shared by every endpoint and replay of one emission: deduplicate on it |
PEPTPlus-Delivery | whd_ id, unique per attempt row |
Respond with any 2xx within 10 seconds. Redirects are not followed. Patients appear only as pt_ tokens.
A failed attempt (non-2xx, timeout or connection error) is retried 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours after the previous attempt, then the delivery is marked dead. Deliveries go out from a dispatcher that runs every 15 minutes, so early retries land on its next run. After 50 consecutive failures the endpoint is disabled (active: false with disabled_reason); re-enable it from the portal, which resets the counter, or register a new one. Any delivery can be replayed from the portal log; a replay is a new delivery with the same event id.
| Event | Sent to | When | data |
|---|---|---|---|
order.created | pharmacy, provider | A paid order was created for a prescription | { order } |
order.accepted | pharmacy, provider | The pharmacy acknowledged the order (in_fulfillment) | { order, source } |
order.status_changed | pharmacy, provider | Any order status transition | { order, previous_status, source } |
shipment.created | pharmacy, provider | Carrier and tracking recorded; order shipped | { order, source } |
shipment.delivered | pharmacy, provider | Order status reached delivered | { order, source } |
prescription.routed | pharmacy | A signed prescription was routed to the pharmacy, before payment | { prescription } |
batch.verified | pharmacy | PEPTPlus verified the listing; product live with its latest lot | { product, batch } |
listing.licensed | lab | A pharmacy licensed one of the lab's API listings | { license } |
ping | any | Test from the portal or the test route | { message, endpoint } |
order is the same object GET /v1/orders/{id} returns. source is pharmacy (a portal user or API key) or system (payment, pharmacy-system sync). The prescription, product, batch and license shapes are in the OpenAPI document under webhooks.
Compute HMAC-SHA256 over "<t>.<raw body>" with your secret, compare to v1 in constant time, and reject timestamps outside your tolerance (5 minutes suggested). Always use the raw request bytes, never a re-serialized object.
https://api.pept.plus/v1 (production) and https://sandbox.api.pept.plus/v1 (an alias of the demo sandbox) will serve the same routes with /v1 in place of /api/v1. The application already answers on those hosts; the DNS and certificate bindings are pending. Until they are marked live above, use pept.plus/api/v1 and demo.pept.plus/api/v1.
There is no POST /v1/prescriptions and no prescribe:write scope. A controlled prescription must be signed by the prescriber with their own two-factor credential at the moment of signing, inside the certified application, and an API key identifies an organization's server rather than a practitioner, so it cannot perform that signing step. A prescriber-keyed flow for non-controlled prescriptions (the API drafts on behalf of a named, licensed prescriber who signs in the portal) is planned after EPCS certification is complete. Until then, prescriptions reach pharmacies through the portal, and pharmacy systems receive them through prescription.routed and the order routes.
The API never returns or accepts NCPDP SCRIPT messages; SCRIPT is used internally for transmission to pharmacies. Pharmacy management systems integrate through the fulfillment routes and webhooks above.