Bespoke Prescription API — Developer Guide
Version 1
This guide is everything you need to integrate with the Bespoke Prescription API: authentication, endpoints, request and response payloads, status lifecycle, errors, and recommended patterns.
What the API does
You submit one prescription for one patient. Bespoke:
- Validates your payload
- Creates (or reuses) the patient record at the pharmacy network
- Transmits the prescription for fulfilment
- Updates status as the order is accepted, shipped, and delivered
You track progress by polling GET /prescriptions/{submission_id} (or listing recent
submissions). Outbound webhooks to your system are planned for a later version.
Your system → POST /v1/prescriptions → Bespoke → Pharmacy network
Your system ← GET /v1/prescriptions/{id} ← status updates
You do not call a separate “create patient” endpoint. Patient creation is handled automatically when the prescription is relayed.
Base URL
https://bespokeprescriptions.com/v1
Replace the host with the URL your account manager provides for sandbox or production.
All requests must use HTTPS. Plain HTTP is rejected.
Authentication
Every request requires two headers:
| Header | Example | Notes |
|---|---|---|
X-Api-Key |
bsk_live_7Qd3xR2mVn8pLc4KfW1sTbYz |
Public key id |
X-Api-Secret |
(your secret) | Shown once when issued |
GET /v1/ping HTTP/1.1
Host: api.bespoke.example
X-Api-Key: bsk_live_7Qd3xR2mVn8pLc4KfW1sTbYz
X-Api-Secret: <your secret>
Accept: application/json
Credentials are issued in the Bespoke partner dashboard. The secret cannot be recovered later; if it is lost, rotate the credential and deploy the new pair.
Rotation issues a new pair and keeps the previous pair valid for 24 hours so you can cut over without downtime.
| Situation | HTTP | Code |
|---|---|---|
| Missing or malformed headers | 401 | unauthenticated |
| Unknown key, wrong secret, or revoked | 401 | invalid_credentials |
| Account temporarily paused | 403 | account_paused |
| Account closed | 403 | account_disabled |
Never put credentials in a query string. Never call the API from a browser or mobile app — only from your backend.
Sandbox keys use the bsk_test_ prefix. Live keys use bsk_live_.
Request and response conventions
| Rule | Detail |
|---|---|
| Content type | Content-Type: application/json and Accept: application/json |
| Encoding | UTF-8 |
| Timestamps | ISO 8601, e.g. 2026-08-12T11:14:03Z |
| Request id | Every response includes X-Request-Id. Log it; support needs it |
Idempotency
POST /prescriptions must include a unique Idempotency-Key header:
Idempotency-Key: 8f14e45f-ea5e-4f1a-b2c3-9d1e0a7c5b62
Use a UUID, or any stable value unique to that prescription in your system (for example your order id). Keys are scoped to your account and retained for 30 days.
| Scenario | Behaviour |
|---|---|
| First request with a key | Processed normally |
| Same key + same body | Original response is replayed (Idempotent-Replay: true). No second prescription |
| Same key + different body | 409 idempotency_conflict — use a new key |
| Validation failure (4xx) | Key is not consumed — fix the payload and retry with the same key |
| Network timeout / no response | Safe to retry with the same key |
Submission later failed |
Terminal. Fix data and POST again with a new key |
Rate limits
120 requests per minute per account (default). Over the limit:
- HTTP
429 - Code
rate_limited - Header
Retry-After(seconds)
Back off and retry. Do not busy-loop.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/ping |
Verify credentials |
POST |
/prescriptions |
Submit a prescription |
GET |
/prescriptions/{submission_id} |
Retrieve one submission |
GET |
/prescriptions |
List submissions |
GET /ping
Smoke-test credentials (useful in deploy pipelines).
curl "https://bespokeprescriptions.com/v1/ping" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json"
200 OK
{
"status": "ok",
"account": "Acme Health",
"environment": "live",
"server_time": "2026-08-12T11:14:03+00:00"
}
| Field | Meaning |
|---|---|
environment |
sandbox for bsk_test_ keys, otherwise live |
account |
Your partner account display name |
POST /prescriptions
Submit one prescription for one patient. Returns 202 Accepted: the request is queued for relay to the pharmacy network; it is not yet confirmed by the pharmacy.
curl -X POST "https://bespokeprescriptions.com/v1/prescriptions" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Idempotency-Key: 8f14e45f-ea5e-4f1a-b2c3-9d1e0a7c5b62" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d @prescription.json
Example request body
{
"patient": {
"external_ref": "acme-patient-90210",
"first_name": "Jane",
"last_name": "Doe",
"date_of_birth": "1984-01-16",
"gender": "female",
"email": "jane.doe@example.com",
"phone": "3055550142",
"address": {
"line1": "123 Main Street",
"line2": "Apt 4",
"city": "Miami",
"state": "FL",
"postal_code": "33101"
}
},
"prescriber": {
"npi": "1104970417",
"first_name": "Patrick",
"last_name": "Kavanagh",
"phone": "+17864964094",
"address": {
"line1": "450 Medical Plaza",
"line2": "",
"city": "Anthem",
"state": "AZ",
"postal_code": "85086"
}
},
"prescription": {
"medication": "Semaglutide",
"strength": "0.25mg",
"directions": "Inject 0.25 mg subcutaneously once weekly",
"quantity": 4,
"days_supply": 28,
"refills": 0
},
"shipping_address": {
"line1": "123 Main Street",
"line2": "Apt 4",
"city": "Miami",
"state": "FL",
"postal_code": "33101"
},
"metadata": {
"your_order_id": "ACME-88213"
}
}
Field reference
patient (required)
| Field | Type | Required | Rules |
|---|---|---|---|
external_ref |
string | yes | Your patient id. Reuse across submissions for the same person. Max 191 |
first_name |
string | yes | Max 255 |
last_name |
string | yes | Max 255 |
date_of_birth |
string | yes | YYYY-MM-DD, must be before today |
gender |
string | yes | male, female, m, or f (any common capitalisation) |
email |
string | no | Valid email, max 255 |
phone |
string | yes | US phone; punctuation accepted and normalised. Max 50 |
address |
object | yes | See address fields below |
patient.address / prescriber.address / shipping_address
| Field | Type | Required | Rules |
|---|---|---|---|
line1 |
string | yes | Max 255 |
line2 |
string | no | Max 255 |
city |
string | yes | Max 255 |
state |
string | yes | Exactly 2 letters (US state code), e.g. FL |
postal_code |
string | yes | Exactly 5 digits |
prescriber (required)
You supply the prescribing clinician. Bespoke does not assign a prescriber.
| Field | Type | Required | Rules |
|---|---|---|---|
npi |
string | yes | Exactly 10 digits |
first_name |
string | yes | Max 255 |
last_name |
string | yes | Max 255 |
phone |
string | yes | Prefer E.164, e.g. +17864964094. Max 50 |
address |
object | yes | Same rules as patient address |
prescription (required)
| Field | Type | Required | Rules |
|---|---|---|---|
medication |
string | yes | Product name only. Do not append strength here |
strength |
string | no | e.g. 0.25mg. Max 100 |
directions |
string | yes | Sig / directions. Max 1000 |
quantity |
number | yes | Must be greater than zero |
days_supply |
integer | no | 1–365 |
refills |
integer | yes | 0–11 |
shipping_address (optional)
Where the medication is shipped. If omitted, the patient address is used. Same field rules as other addresses.
metadata (optional)
Your own key-value bag. Returned unchanged on every read.
| Rule | Limit |
|---|---|
| Max keys | 10 |
| Values | strings, max 255 characters each |
Do not put clinical data here. Useful for your internal order id.
Response — 202 Accepted
{
"submission_id": "b4f0c1d2-9a76-4a1e-8f3b-2c5d7e9a0b11",
"status": "queued",
"patient_ref": "acme-patient-90210",
"medication": "Semaglutide",
"strength": "0.25mg",
"quantity": 4,
"refills": 0,
"created_at": "2026-08-12T11:14:03+00:00",
"updated_at": "2026-08-12T11:14:03+00:00",
"metadata": {
"your_order_id": "ACME-88213"
},
"timeline": [
{
"status": "queued",
"at": "2026-08-12T11:14:03+00:00"
}
]
}
Persist submission_id. It is the only identifier you need for subsequent GETs.
GET /prescriptions/{submission_id}
Fetch one submission by the submission_id returned from POST.
curl "https://bespokeprescriptions.com/v1/prescriptions/b4f0c1d2-9a76-4a1e-8f3b-2c5d7e9a0b11" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json"
200 OK (shipped example)
{
"submission_id": "b4f0c1d2-9a76-4a1e-8f3b-2c5d7e9a0b11",
"status": "shipped",
"patient_ref": "acme-patient-90210",
"medication": "Semaglutide",
"strength": "0.25mg",
"quantity": 4,
"refills": 0,
"tracking": {
"carrier": "UPS",
"number": "1Z999AA10123456784"
},
"timeline": [
{ "status": "queued", "at": "2026-08-12T11:14:03+00:00" },
{ "status": "accepted", "at": "2026-08-12T11:14:19+00:00" },
{ "status": "shipped", "at": "2026-08-13T16:02:44+00:00" }
],
"created_at": "2026-08-12T11:14:03+00:00",
"updated_at": "2026-08-13T16:02:44+00:00",
"metadata": {
"your_order_id": "ACME-88213"
}
}
200 OK (failed example)
{
"submission_id": "b4f0c1d2-9a76-4a1e-8f3b-2c5d7e9a0b11",
"status": "failed",
"patient_ref": "acme-patient-90210",
"medication": "Semaglutide",
"strength": "0.25mg",
"quantity": 4,
"refills": 0,
"error": {
"code": "pharmacy_rejected",
"message": "Prescriber NPI is invalid. Fields: {\"prescribedBy.npi\":\"must be a valid NPI\"}"
},
"timeline": [
{ "status": "queued", "at": "2026-08-12T11:14:03+00:00" },
{ "status": "failed", "at": "2026-08-12T11:14:25+00:00" }
],
"created_at": "2026-08-12T11:14:03+00:00",
"updated_at": "2026-08-12T11:14:25+00:00",
"metadata": {
"your_order_id": "ACME-88213"
}
}
| Response field | When present |
|---|---|
tracking |
After shipment, when a tracking number is available |
error |
When status is failed (and on some intermediate failures while retrying) |
error.message is actionable detail from the pharmacy network (vendor product names removed).
Use it to correct your data and submit again with a new Idempotency-Key.
404 — unknown id, or a submission that belongs to another account / outside retention.
GET /prescriptions
List your submissions, newest first. Scoped to your account and retention window (default 90 days).
| Query parameter | Type | Notes |
|---|---|---|
status |
string | Filter by status value |
patient_ref |
string | Filter by your patient.external_ref |
created_after |
date / datetime | Inclusive lower bound |
created_before |
date / datetime | Inclusive upper bound |
per_page |
integer | 1–100, default 25 |
cursor |
string | Opaque cursor from meta.next_cursor |
curl "https://bespokeprescriptions.com/v1/prescriptions?status=failed&per_page=25" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json"
{
"data": [
{
"submission_id": "b4f0c1d2-9a76-4a1e-8f3b-2c5d7e9a0b11",
"status": "failed",
"patient_ref": "acme-patient-90210",
"medication": "Semaglutide",
"strength": "0.25mg",
"quantity": 4,
"refills": 0,
"error": {
"code": "pharmacy_rejected",
"message": "…"
},
"timeline": […],
"created_at": "2026-08-12T11:14:03+00:00",
"updated_at": "2026-08-12T11:14:25+00:00",
"metadata": {}
}
],
"meta": {
"per_page": 25,
"next_cursor": "eyJpZCI6NDgyMX0"
}
}
Pass cursor=<next_cursor> for the next page. When next_cursor is null, you are done.
Status lifecycle
queued → relaying → accepted → shipped → delivered
↘ failed
shipped → voided
(admin) → cancelled
| Status | Meaning | Terminal? |
|---|---|---|
queued |
Accepted by Bespoke; waiting for relay | No |
relaying |
Relay job in progress | No |
accepted |
Pharmacy network accepted the prescription | No |
shipped |
Dispensed and shipped; tracking often present | No |
delivered |
Delivered to the patient | Yes |
voided |
Shipment voided before delivery | Yes |
cancelled |
Cancelled by Bespoke operations | Yes |
failed |
Could not be relayed after retries | Yes |
Polling guidance
- Poll no more than once per minute per submission.
- Most submissions reach
acceptedwithin seconds when the queue is healthy. shippedtypically follows within a business day once fulfilment starts.- After
failed, do not keep polling the same id for recovery — create a new submission.
Errors
HTTP error shape
All API errors use one envelope:
{
"error": {
"code": "validation_failed",
"message": "The submission could not be processed because some fields are invalid.",
"request_id": "5c2a1f80-6f0e-4d3a-9d1b-77a0c2e6b4f9",
"fields": {
"patient.date_of_birth": ["Must be a valid date in the format YYYY-MM-DD."],
"prescription.quantity": ["Must be greater than zero."]
}
}
}
fields appears only on 422 validation_failed.
HTTP status codes
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| 400 | malformed_request |
Body is not valid JSON | Fix the request |
| 401 | unauthenticated |
Credential headers missing | Add X-Api-Key / X-Api-Secret |
| 401 | invalid_credentials |
Key or secret wrong, or revoked | Check your secret store |
| 403 | account_paused |
Account temporarily paused | Contact your account manager |
| 403 | account_disabled |
Account closed | Contact your account manager |
| 404 | not_found |
No such submission for your account | Check the id |
| 409 | idempotency_conflict |
Key reused with a different body | Use a new key |
| 422 | validation_failed |
One or more fields invalid | Fix fields and retry (same key OK) |
| 429 | rate_limited |
Too many requests | Honour Retry-After |
| 500 | server_error |
Unexpected failure on our side | Retry with the same idempotency key |
| 503 | pharmacy_unavailable |
Pharmacy network unreachable | Retry later; submission may already be queued |
Submission-level failure codes
These appear on a failed submission under error.code (not as the HTTP response to POST,
which was already 202):
| Code | Meaning |
|---|---|
pharmacy_rejected |
Pharmacy network declined the prescription |
patient_rejected |
Patient could not be created downstream |
prescriber_invalid |
Prescriber details were not accepted |
relay_failed |
Repeated delivery failures with no successful relay |
pharmacy_unavailable |
Downstream unavailable after retries exhausted |
Read error.message, correct the payload, and submit again with a new Idempotency-Key.
Recommended integration flow
- Call
GET /pingfrom your deploy pipeline with sandbox credentials. - On each order, generate a unique
Idempotency-KeyandPOST /prescriptions. - Store
submission_id(andX-Request-Id) against your order. - Poll
GET /prescriptions/{submission_id}until a terminal status, or list bypatient_ref. - If
failed, showerror.messageto your operators, fix data, submit a new request. - If the HTTP call times out, retry the same key — do not invent a second prescription.
Minimal happy-path sequence
GET /v1/ping
POST /v1/prescriptions → 202, status=queued, save submission_id
GET /v1/prescriptions/{id} → status=relaying | accepted | …
GET /v1/prescriptions/{id} → status=shipped (+ tracking)
GET /v1/prescriptions/{id} → status=delivered
Partner dashboard
In addition to the API, your team can sign in to the partner dashboard to:
- View submission history and status
- Inspect failure messages
- Rotate API credentials
Dashboard access is separate from API keys (email/password for your users). Ask your account manager to invite operators.
Sandbox vs live
| Sandbox | Live | |
|---|---|---|
| Key prefix | bsk_test_ |
bsk_live_ |
GET /ping → environment |
sandbox |
live |
| Fulfilment | Test / non-production pharmacy path | Real fulfilment |
Use sandbox credentials until your payloads consistently reach accepted. Then switch to live
keys and the production base URL your account manager provides.
Integration checklist
- Credentials stored in a secret manager (never in source control)
- All calls made server-to-server over HTTPS
- Unique
Idempotency-Keyper prescription; reuse only for safe retries of the same body -
submission_idpersisted on your order record -
X-Request-Idcaptured in your logs - Retries with backoff on
429,500, and503 - Failed submissions handled with a new key after fixing
error.message - Polling capped (≤ 1/minute per submission)
- Tested end-to-end against sandbox before go-live
Support
Contact your account manager, or email support@bespoke.example.
Always include:
submission_idand/orrequest_id(X-Request-Id)- Approximate time of the request (UTC)
- Whether you are on sandbox or live