Developers

PEPTPlus API

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.

REST, JSONAPI-key authTenant scopedSigned webhooksOpenAPI 3.1

Overview

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.

EnvironmentBase URLKeysStatus
Productionhttps://pept.plus/api/v1sk_*_live_Live
Production, dedicated hosthttps://api.pept.plus/v1sk_*_live_Planned until the host is bound
Sandbox (synthetic data)https://demo.pept.plus/api/v1sk_*_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.

PHI notice. Order and prescription payloads carry Protected Health Information. Patients appear only as 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.

Authentication

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.

curl https://pept.plus/api/v1/pharmacy/orders \ -H "Authorization: Bearer sk_pharm_live_8f3a..."

Key format

sk_<org>_<env>_<48 hex> org: prov | pharm | lab env: live | test sk_prov_live_... provider practice, production sk_pharm_test_... pharmacy, sandbox
  • Live keys (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.
  • Test keys (sk_*_test_) are sandbox keys. The demo portal mints test keys only, whatever was asked for, against synthetic data.
  • A key presented on the wrong environment, a revoked key, or a key of an organization that is pending or suspended is refused with 401 authentication_error.

Scopes

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.

OrganizationScopes 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.

Headers

HeaderDirectionMeaning
AuthorizationrequestBearer sk_..., required on every route.
X-Request-IdbothOptional 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-KeyrequestOptional on writes, 1 to 255 characters. See Idempotency.
X-API-VersionresponseThe contract date this response follows: 2026-10-29.
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-ResetresponseRequests allowed per 60 second window for this key, requests left, and the unix time the window ends. Present on every authenticated response.
Retry-AfterresponseSeconds to wait, on 429 only.
Idempotent-Replayedresponsetrue when the body is the stored response for your Idempotency-Key.
Deprecation, Sunset, LinkresponseOnly 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.

Errors

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).

{ "error": { "type": "permission_error", "message": "This key lacks the required scope: orders:read.", "request_id": "2b1f6d0e-4a1c-4b7e-9f3a-0c8d7e6f5a4b", "required": "orders:read" } }
HTTPtypeWhen
401authentication_errorMissing, malformed, unknown, revoked or wrong-environment key; organization not active.
403permission_errorKey lacks the scope (required names it) or its organization type cannot call the route.
404not_foundNo such object for your organization, or no such API route.
422validation_errorBad body or query parameter; details carries field errors when a schema failed.
422idempotency_errorThe Idempotency-Key was already used with a different request body.
429rate_limit_errorPer-key limit exceeded, or too many failed authentications from one address.
500internal_errorUnexpected 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:

$ curl -s https://pept.plus/api/v1/prescriptions { "error": { "type": "not_found", "message": "No such API route. See https://pept.plus/developers for the route list.", "request_id": "274391b7-d59d-4c97-b8a9-c0bf3c890b60" } }

Rate limits

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.

Pagination

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.

curl https://pept.plus/api/v1/orders?status=paid&limit=50&starting_after=ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e \ -H "Authorization: Bearer sk_prov_live_8f3a..."

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).

Idempotency

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.

curl -X POST https://pept.plus/api/v1/pharmacy/orders/ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e/shipment \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Idempotency-Key: 7a1d2c3b-4e5f-4a6b-9c8d-0e1f2a3b4c5d" \ -H "Content-Type: application/json" \ -d '{ "carrier": "USPS", "tracking_no": "9400100000000000000000" }'

Honored on every POST, PATCH and DELETE except two: the certificate of analysis upload and the webhook test send, which ignore the header.

Versioning

  • The path version (/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.
  • Each response states its contract date in X-API-Version. The date advances when behaviour changes in a way worth reading about; the changelog below lists each date.
  • When a route or field must go, it is announced in the changelog and the route starts sending Deprecation, Sunset and Link headers at least 12 months before removal.

Changelog

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.

Catalog Live

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.

GET/v1/catalog?limit=&starting_after=catalog:readPage of compounds
GET/v1/catalog/{id}catalog:readOne compound
curl https://pept.plus/api/v1/catalog?limit=25 \ -H "Authorization: Bearer sk_prov_live_8f3a..."
{ "object": "list", "has_more": false, "data": [ { "id": "cmp_5f2c7a10-3b4e-4d6f-9a8b-1c2d3e4f5a6b", "object": "compound", "name": "BPC-157", "eligibility_basis": "503A", "strengths": ["5 mg"], "routes": ["subq"], "doses": [{ "value": "5", "unit": "mg" }] } ] }
Compound
FieldTypeNotes
idstringcmp_ id. Use it as compound when creating a pharmacy product.
namestringDisplay name.
eligibility_basisstring or nullWhy the compound is prescribable, for example 503A.
strengthsstring[]Human readable strengths available.
routesstring[]Delivery routes available (subq, im, iv, topical, edible).
dosesobject[]Each { value, unit }; unit is one of mg mcg g IU mL mg/mL mcg/mL IU/mL mEq %.
curl https://pept.plus/api/v1/catalog/cmp_5f2c7a10-3b4e-4d6f-9a8b-1c2d3e4f5a6b \ -H "Authorization: Bearer sk_prov_live_8f3a..."

Returns the compound object, or 404 not_found when the id is unknown or not prescribable for you.

Orders Live

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).

GET/v1/orders?status=&limit=&starting_after=orders:readPage of orders, newest first
GET/v1/orders/{id}orders:readOne order with its latest shipment
Query parameters for the list
FieldTypeNotes
statusstringOptional filter: created, awaiting_payment, paid, sent_to_pharmacy, in_fulfillment, shipped, delivered, action_needed, canceled. Anything else returns 422.
limitinteger1 to 100, default 25.
starting_afterstringCursor: the last id of the previous page.
curl https://pept.plus/api/v1/orders?status=shipped&limit=25 \ -H "Authorization: Bearer sk_prov_live_8f3a..."
curl https://pept.plus/api/v1/orders/ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e \ -H "Authorization: Bearer sk_prov_live_8f3a..."
{ "id": "ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e", "object": "order", "status": "shipped", "channel": "web", "patient": "pt_8f3a1b2c-4d5e-4f60-a1b2-c3d4e5f6a7b8", "compound": "BPC-157", "strength": { "value": "5", "unit": "mg" }, "route": "subq", "amount": { "patient_paid_cents": 14900, "platform_fee_cents": 0, "currency": "usd" }, "shipment": { "carrier": "USPS", "tracking_no": "9400100000000000000000", "status": "in_transit" }, "created_at": "2026-10-28T17:02:11.000Z", "updated_at": "2026-10-29T14:30:00.000Z" }
Order
FieldTypeNotes
idstringord_ id.
statusstringOne of the statuses listed above.
channelstringHow the order was created, for example web or api.
patientstring or nullpt_ token. The only patient identifier the API returns.
compoundstring or nullCompound display name.
strengthobject or null{ value, unit }.
routestring or nullDelivery route.
amountobjectpatient_paid_cents and platform_fee_cents as integer cents (or null), currency usd. The platform fee per prescription is $0.
shipmentobject or null{ carrier, tracking_no, status } for the latest shipment, null before one is recorded.
created_at, updated_atstringISO 8601 UTC timestamps.

Pharmacy fulfillment Live

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.

GET/v1/pharmacy/orders?status=&limit=&starting_after=pharmacy:fulfillYour fulfillment queue
POST/v1/pharmacy/orders/{id}/acceptpharmacy:fulfillAcknowledge: status becomes in_fulfillment
POST/v1/pharmacy/orders/{id}/statuspharmacy:fulfillSet in_fulfillment, shipped, delivered or action_needed
POST/v1/pharmacy/orders/{id}/shipmentpharmacy:fulfillRecord carrier and tracking; status becomes shipped

Queue

Same parameters and shape as GET /v1/orders (status filter, limit, starting_after, has_more), scoped to orders routed to your pharmacy.

curl https://pept.plus/api/v1/pharmacy/orders?status=paid \ -H "Authorization: Bearer sk_pharm_live_8f3a..."

Accept

Empty body. Moves the order to in_fulfillment and returns it. 404 when the order is not routed to your pharmacy.

curl -X POST https://pept.plus/api/v1/pharmacy/orders/ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e/accept \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Idempotency-Key: 1f7c9a2e-accept-ord-6c20"

Status

Request body
FieldTypeNotes
statusstringRequired. One of in_fulfillment, shipped, delivered, action_needed. Payment statuses cannot be set through the API.
curl -X POST https://pept.plus/api/v1/pharmacy/orders/ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e/status \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Content-Type: application/json" \ -d '{ "status": "delivered" }'

Shipment

Request body
FieldTypeNotes
carrierstringRequired, 1 to 64 characters.
tracking_nostringRequired, 1 to 128 characters. Stored with the shipment; never written to the audit log.
curl -X POST https://pept.plus/api/v1/pharmacy/orders/ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e/shipment \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Content-Type: application/json" \ -d '{ "carrier": "USPS", "tracking_no": "9400100000000000000000" }'
{ "id": "ord_6c20a1f0-9b2e-4c1a-8e7d-3f5a2b1c0d9e", "object": "order", "status": "shipped", "shipment": { "carrier": "USPS", "tracking_no": "9400100000000000000000", "status": "in_transit" }, "...": "remaining order fields as under Orders" }
Controlled substances. Accepting or advancing an order for a controlled prescription re-verifies the prescriber's signature and DEA registration. An order whose prescription fails that check, or that is still blocked on transmission, is refused with 422 validation_error and the reason in message. Acceptance through the API is recorded in the e-prescription transmission log with transport partner_api.
Payments. The drug charge is paid to your pharmacy through Stripe Connect and PEPTPlus takes $0 per prescription. The API never moves money.

Pharmacy catalog Live

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.

GET/v1/pharmacy/products?channel=&status=&limit=&starting_after=pharmacy:catalogYour products
POST/v1/pharmacy/productspharmacy:catalogCreate a product (pending review)
GET/v1/pharmacy/products/{id}pharmacy:catalogOne product
PATCH/v1/pharmacy/products/{id}pharmacy:catalogChange price, availability or form
GET/v1/pharmacy/batches?product=&limit=&starting_after=pharmacy:catalogYour lots with latest COA
POST/v1/pharmacy/batchespharmacy:catalogCreate a lot
GET/v1/pharmacy/batches/{id}pharmacy:catalogOne lot
POST/v1/pharmacy/batches/{id}/coapharmacy:catalogUpload a COA PDF (multipart or base64)

Products

Filters: channel is patient_rx (503A, patient prescriptions) or office_stock (503B, office stock); status is live, pending_review, draft, unlisted or hidden.

curl https://pept.plus/api/v1/pharmacy/products?channel=patient_rx&status=live \ -H "Authorization: Bearer sk_pharm_live_8f3a..."
{ "id": "prd_1d2e3f40-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "object": "product", "compound": "cmp_5f2c7a10-3b4e-4d6f-9a8b-1c2d3e4f5a6b", "name": "BPC-157", "channel": "patient_rx", "status": "live", "published": true, "delivery_method": "subq", "strength": { "value": "5", "unit": "mg" }, "dosage_form_code": "C42945", "quantity_uom_code": "C48542", "pricing": { "patient_price": "149.00", "price_basis": "per_rx", "currency": "usd" }, "latest_batch": { "id": "bat_7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "lot_no": "L-2041", "quantity": 120, "expiry_date": "2027-03-01", "coa_status": "on_file" }, "created_at": "2026-10-01T15:00:00.000Z", "updated_at": "2026-10-20T09:12:00.000Z" }

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).

Create

Request body
FieldTypeNotes
compoundstringRequired. cmp_ id from GET /v1/catalog. A compound not prescribable through the chosen channel returns 422.
channelstringpatient_rx (default) or office_stock.
delivery_methodstringRequired. subq, im, iv, topical or edible.
strengthobjectRequired. { value: number or decimal string, unit } with unit from the strength list.
dosage_form_code, quantity_uom_codestringRequired. NCPDP dosage form and dispensing unit codes, 1 to 16 characters.
patient_price, price_basisstring, stringpatient_rx: price required unless published is false; price_basis per_rx or per_unit.
pack_size, wholesale_price, lead_time_daysinteger, string, integerAll three required for office_stock.
publishedbooleanfalse saves a draft that is not submitted for review. Default: submit for review.
batchobjectOptional first lot: { lot_no, quantity?, expiry_date? }.
curl -X POST https://pept.plus/api/v1/pharmacy/products \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Content-Type: application/json" \ -d '{ "compound": "cmp_5f2c7a10-3b4e-4d6f-9a8b-1c2d3e4f5a6b", "channel": "patient_rx", "delivery_method": "subq", "strength": { "value": 5, "unit": "mg" }, "dosage_form_code": "C42945", "quantity_uom_code": "C48542", "patient_price": 149, "price_basis": "per_rx", "batch": { "lot_no": "L-2041", "quantity": 120, "expiry_date": "2027-03-01" } }'

Returns 201 with the product in pending_review (or draft). It goes live after PEPTPlus verification; subscribe to batch.verified to hear about it.

Update

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.

curl -X PATCH https://pept.plus/api/v1/pharmacy/products/prd_1d2e3f40-5a6b-4c7d-8e9f-0a1b2c3d4e5f \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Content-Type: application/json" \ -d '{ "patient_price": "159.00", "published": true }'
curl https://pept.plus/api/v1/pharmacy/products/prd_1d2e3f40-5a6b-4c7d-8e9f-0a1b2c3d4e5f \ -H "Authorization: Bearer sk_pharm_live_8f3a..."

Lots

curl https://pept.plus/api/v1/pharmacy/batches?product=prd_1d2e3f40-5a6b-4c7d-8e9f-0a1b2c3d4e5f \ -H "Authorization: Bearer sk_pharm_live_8f3a..."
{ "id": "bat_7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "object": "batch", "product": "prd_1d2e3f40-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "compound": "BPC-157", "strength": { "value": "5", "unit": "mg" }, "lot_no": "L-2041", "quantity": 120, "mfg_date": "2026-09-15", "expiry_date": "2027-03-01", "coa": { "id": "coa_2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "object": "coa", "status": "on_file", "purity_pct": "99.2", "lab_name": "Janoshik", "tested_on": "2026-09-20", "uploaded_at": "2026-10-02T11:00:00.000Z" }, "created_at": "2026-10-01T15:00:00.000Z" }
Create a lot: request body
FieldTypeNotes
productstringRequired. prd_ id.
lot_nostringRequired, 1 to 64 characters.
quantityintegerOptional, 0 or more.
mfg_date, expiry_datestringOptional. MM/YYYY, YYYY-MM or YYYY-MM-DD.
curl -X POST https://pept.plus/api/v1/pharmacy/batches \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Content-Type: application/json" \ -d '{ "product": "prd_1d2e3f40-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "lot_no": "L-2042", "quantity": 80, "mfg_date": "2026-10-10", "expiry_date": "2027-04-01" }'
curl https://pept.plus/api/v1/pharmacy/batches/bat_7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d \ -H "Authorization: Bearer sk_pharm_live_8f3a..."

Certificate of analysis

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.

curl -X POST https://pept.plus/api/v1/pharmacy/batches/bat_7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d/coa \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -F "file=@coa-L-2041.pdf;type=application/pdf" \ -F "purity_pct=99.2" -F "lab_name=Janoshik" -F "tested_on=2026-09-20"

Lab listings and licensees Live

Read your lab's API listings and the pharmacies licensing them. Scopes listings:read and licensees:read; lab keys only.

GET/v1/listings?limit=&starting_after=listings:readYour listings with licensee counts
GET/v1/listings/{id}/licenseeslicensees:readPharmacies licensing one listing
curl https://pept.plus/api/v1/listings \ -H "Authorization: Bearer sk_lab_live_8f3a..."
{ "object": "list", "has_more": false, "data": [ { "id": "lst_3a91c0de-7f21-4b3a-9e55-0a1b2c3d4e5f", "object": "listing", "substance": "Sermorelin", "monthly_fee": "499", "currency": "usd", "certified": true, "eligibility_note": null, "status": "active", "licensee_count": 4 } ] }
Listing
FieldTypeNotes
idstringlst_ id.
substancestringCanonical substance name.
monthly_feestring or nullDecimal dollars.
certifiedbooleanWhether PEPTPlus has certified the listing.
eligibility_notestring or nullFree text from the eligibility register.
statusstringactive or coming.
licensee_countintegerActive licenses against this listing.
curl https://pept.plus/api/v1/listings/lst_3a91c0de-7f21-4b3a-9e55-0a1b2c3d4e5f/licensees \ -H "Authorization: Bearer sk_lab_live_8f3a..."
{ "object": "list", "data": [ { "id": "lic_0b7d2e4f-6a8c-4e1b-b3d5-7f9a1c3e5b7d", "object": "license", "pharmacy": "org_9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b", "status": "active", "monthly_fee": "499", "currency": "usd", "started_at": "2026-09-01T00:00:00.000Z", "canceled_at": null } ] }

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.

Webhooks Live

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.

GET/v1/webhookswebhooks:manageYour endpoints (at most 10)
POST/v1/webhookswebhooks:manageRegister an endpoint; secret returned once
GET/v1/webhooks/{id}webhooks:manageOne endpoint with its 20 latest deliveries
DELETE/v1/webhooks/{id}webhooks:manageRemove an endpoint
POST/v1/webhooks/{id}/testwebhooks:manageQueue a ping event

Register

Request body
FieldTypeNotes
urlstringRequired. https on a public host, 12 to 2048 characters. No embedded credentials, loopback or private addresses.
descriptionstring or nullOptional, up to 200 characters.
eventsstring[]Required, 1 to 32 event types from the catalog below, or ["*"] for everything. Every endpoint also receives ping.
curl -X POST https://pept.plus/api/v1/webhooks \ -H "Authorization: Bearer sk_pharm_live_8f3a..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/peptplus/webhooks", "description": "Pharmacy system, production", "events": ["order.created", "order.status_changed", "shipment.created"] }'
{ "id": "whe_4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8", "object": "webhook_endpoint", "url": "https://example.com/peptplus/webhooks", "description": "Pharmacy system, production", "events": ["order.created", "order.status_changed", "shipment.created"], "active": true, "disabled_reason": null, "secret_hint": "9c4e", "secret": "whsec_0123456789abcdef0123456789abcdef0123456789ab9c4e", "stats": { "delivered": 0, "failed": 0, "consecutive_failures": 0, "last_delivered_at": null }, "created_at": "2026-10-29T10:00:00.000Z", "updated_at": "2026-10-29T10:00:00.000Z" }

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:

curl https://pept.plus/api/v1/webhooks \ -H "Authorization: Bearer sk_pharm_live_8f3a..."
curl https://pept.plus/api/v1/webhooks/whe_4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8 \ -H "Authorization: Bearer sk_pharm_live_8f3a..."

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.

curl -X POST https://pept.plus/api/v1/webhooks/whe_4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8/test \ -H "Authorization: Bearer sk_pharm_live_8f3a..."

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.

curl -X DELETE https://pept.plus/api/v1/webhooks/whe_4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8 \ -H "Authorization: Bearer sk_pharm_live_8f3a..."

Returns { "id": "whe_...", "object": "webhook_endpoint", "deleted": true }. Pending deliveries are dropped; the delivery log is kept.

Receiving events

Each event is a POST with Content-Type: application/json, User-Agent: PEPTPlus-Webhooks/1.0 and these headers:

HeaderValue
PEPTPlus-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> keyed with your whsec_ secret
PEPTPlus-Eventthe event type, for example order.created
PEPTPlus-Event-Idevt_ id, shared by every endpoint and replay of one emission: deduplicate on it
PEPTPlus-Deliverywhd_ id, unique per attempt row
{ "id": "evt_2a2f3b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b", "object": "event", "type": "order.status_changed", "created": 1761750000, "api_version": "2026-10-29", "data": { "order": { "id": "ord_6c20...", "object": "order", "status": "in_fulfillment", "...": "..." }, "previous_status": "paid", "source": "pharmacy" } }

Respond with any 2xx within 10 seconds. Redirects are not followed. Patients appear only as pt_ tokens.

Retries

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 catalog

EventSent toWhendata
order.createdpharmacy, providerA paid order was created for a prescription{ order }
order.acceptedpharmacy, providerThe pharmacy acknowledged the order (in_fulfillment){ order, source }
order.status_changedpharmacy, providerAny order status transition{ order, previous_status, source }
shipment.createdpharmacy, providerCarrier and tracking recorded; order shipped{ order, source }
shipment.deliveredpharmacy, providerOrder status reached delivered{ order, source }
prescription.routedpharmacyA signed prescription was routed to the pharmacy, before payment{ prescription }
batch.verifiedpharmacyPEPTPlus verified the listing; product live with its latest lot{ product, batch }
listing.licensedlabA pharmacy licensed one of the lab's API listings{ license }
pinganyTest 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.

Verifying the signature

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.

Node.js

import crypto from 'node:crypto'; import express from 'express'; const SECRET = process.env.PEPTPLUS_WEBHOOK_SECRET; // whsec_... const app = express(); app.post('/peptplus/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('PEPTPlus-Signature') ?? ''; const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('='))); const t = Number(parts.t); const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${req.body}`).digest('hex'); const fresh = Math.abs(Date.now() / 1000 - t) <= 300; const valid = fresh && parts.v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex')); if (!valid) return res.status(400).send('bad signature'); const event = JSON.parse(req.body); // event.id is stable across retries and replays: deduplicate on it. console.log(event.type, event.data); res.sendStatus(200); });

Python

import hashlib, hmac, json, os, time from flask import Flask, request, abort SECRET = os.environ["PEPTPLUS_WEBHOOK_SECRET"].encode() # whsec_... app = Flask(__name__) @app.post("/peptplus/webhooks") def peptplus_webhook(): header = request.headers.get("PEPTPlus-Signature", "") parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv) try: t = int(parts["t"]) except (KeyError, ValueError): abort(400) body = request.get_data() # raw bytes expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest() if abs(time.time() - t) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")): abort(400) event = json.loads(body) # event["id"] is stable across retries and replays: deduplicate on it. print(event["type"], event["data"]) return "", 200

Roadmap

Dedicated hosts Planned

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.

Prescription writes Deferred

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.

Not in scope

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.

Security & complianceHow we protect your data →
HIPAA-firstTokenized, isolated PHI
Tamper-evident auditHash-chained · 7-yr
Encrypted everywhereIn transit & at rest
Hosted on Microsoft AzureHIPAA BAA · Azure SOC 2 and ISO 27001 infrastructure
Verifiable e-RxNCPDP · hashed Rx PDF