Public developer reference Base URL https://a-nirwan.bespoke-consults.designingit.co/v1 Updated Nov 13, 2026

Question catalog

Fetch the intake questions for a treatment category before you build the visit questionnaire. Categories include GLP-1, MICC/B-12, glutathione, peptides, NAD+, B12, ED, Derma, hair, sleep, HRT, TRT, sermorelin, antiviral, and mental health. MICC/B-12 and glutathione are weight-loss intakes. Fetch them with GET https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=micc-b12, category=glutathione, and category=peptides. They do not use the GLP-1 form and they do not enforce a BMI cutoff. Peptides is one form for BPC-157, TB-500, MOTS-c, Epitalon, and Semax. Send requested_product as bpc-157, tb-500, mots-c, epitalon, or semax. Women's HRT is category=hrt (questionnaire code hrt). Men's TRT is category=trt (questionnaire code mens-trt). Send requested_product for the medicine the patient selected, for example estrogen or testosterone-men-s-trt. The same list is returned for every partner.

GET https://a-nirwan.bespoke-consults.designingit.co/v1/question-categories lists categories and the questionnaires inside each one. Each category includes prescription_interval_days when a default gap is configured: that many days between each prescription, unless this partner has its own gap for the category. Send the category slug as questionnaire_type on the visit. The GLP-1 category slug is glp-1. glp1 is also accepted. Any other value returns 422 validation_failed, and the error includes the questionnaires docs URL. GET https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=glp-1 returns the questions. Add questionnaire=sildenafil when a category has more than one form (ED has sildenafil and tadalafil).

Answer types

  • yes_no, single_select — one answer from options.
  • multi_select — one or more answers.
  • short_text, long_text, number, date, height, weight — the partner collects that value. There are no options.
  • info — show the label and do not collect an answer.
  • yes_no_with_answer, and any option whose reveals_input is set — when that option is chosen, show the extra input (usually a text box labeled “Please specify”).

visible_when means the question is shown only after an earlier answer matches. Choosing “Other” either sets reveals_input on that option or reveals a later question whose visible_when.value is Other. How to apply those rules, and which GLP-1 questions depend on an earlier answer, is on the questionnaires page.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=ed&questionnaire=sildenafil" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

Send the collected answers on the visit as questionnaire.answers, using each question key as id.

Medical questionnaire flow

A questionnaire is a treatment-specific medical assessment stored on the visit. It is clinical review data for Bespoke providers — not a prescription, and never sent to the pharmacy network.

End-to-end sequence

  1. POST https://a-nirwan.bespoke-consults.designingit.co/v1/patients — create/upsert the patient (optional if nested on the visit).
  2. POST https://a-nirwan.bespoke-consults.designingit.co/v1/visits — open a consult (pending). Set questionnaire_type to a category slug from GET /question-categories (for example glp-1, micc-b12, glutathione, peptides, hrt, trt, or ed). Include questionnaire, or attach it in the next step. For MICC + B-12 and Glutathione, also set requested_product to micc-b12 or glutathione. For a peptide, set requested_product to bpc-157, tb-500, mots-c, epitalon, or semax. For Women's HRT, set questionnaire_type to hrt and requested_product to the selected hormone, such as estrogen. For Men's TRT, set questionnaire_type to trt, questionnaire.code to mens-trt, and requested_product to the selected medicine, such as testosterone-men-s-trt.
  3. PUT https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/questionnaire — attach or replace answers after your intake form finishes.
  4. GET https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id} — confirm what was stored (optional).
  5. Provider reviews in Bespoke admin → Consultation review.
  6. Wait for a Bespoke provider to review and approve the consult in Bespoke. Partners cannot complete consults via the API.
  7. When a provider approves the consult, Bespoke creates the prescriptions.
  8. GET https://a-nirwan.bespoke-consults.designingit.co/v1/prescriptions/{id} — poll fulfilment status.
  9. Pause / activate remaining fills only if pharmacy routing is activated (Bespoke pharmacy channel). Then POST https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/prescriptions/pause and …/activate after the consult is completed. If routing returns prescriptions to you, these calls return 403 pharmacy_channel_required. Shipped fills cannot be held.

You can open the visit first without answers, then call PUT …/questionnaire later. Replacing overwrites the previous JSON and refreshes questionnaire_submitted_at. When a questionnaire is sent, every question that applies must be answered. A short or partial answer list returns 422 validation_failed and is not stored. Conditional questions are required only after their visible_when rule matches.

Open questionnaire documentation →

Create a visit with a questionnaire

curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/visits" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "external_visit_id": "consult-1001",
    "external_patient_id": "acme-patient-90210",
    "questionnaire_type": "glp-1",
    "questionnaire": {
      "code": "weight",
      "type": "glp-1",
      "answers": [
        { "id": "weight_product", "answer": "semaglutide" },
        { "id": "height", "answer": { "feet": 5, "inches": 10 } },
        { "id": "pounds", "answer": "220" }
      ]
    }
  }'

Consults and questionnaires have no notes field. A notes key on create or update is ignored and is not returned. Put clinical comments in the answer for that question. A choice that asks for extra text uses {"value":"Yes","input":"the explanation"}. note is accepted in place of input on that answer only.

Attach or replace later

curl -X PUT "https://a-nirwan.bespoke-consults.designingit.co/v1/visits/consult-1001/questionnaire" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "questionnaire_type": "glp-1",
    "questionnaire": {
      "code": "weight",
      "type": "glp-1",
      "answers": [
        { "id": "weight_product", "answer": "semaglutide" },
        { "id": "height", "answer": { "feet": 5, "inches": 10 } },
        { "id": "pounds", "answer": "220" }
      ]
    }
  }'

POST to the same path is accepted as an alias.

Payload shape

FieldTypeNotes
typestringRecommended; e.g. glp-1, trt, ed, peptides
versionstringYour schema version, e.g. 1.0
titlestringDisplay title for providers
answersarrayList of { id, question, answer }

The examples above are the shape, not a complete GLP-1 form. Include every visible question. info questions are omitted. Full rules, conditional follow-ups, and the error shape are on the questionnaires page. Keep clinical content here — never on the prescription (directions, Rx metadata, etc.).

Where providers see it

  • Admin → Consultation review — inbox; View shows assessment type + full answers.
  • Partner → Visits / Assessments — read-only view of the same questionnaire.

Isolation rules

  1. Questionnaire fields live only on the visit.
  2. Pharmacy transmit uses Rx / patient / shipping / prescriber only — never the visit questionnaire or visit metadata.
  3. Completing a consult is independent of creating an Rx — Bespoke issues fills on approve.

After the questionnaire

  1. Optionally send photo URLs: POST https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/images with JSON face_url and/or photo_id_url (or include them on visit create/update).
  2. When Lab test files is enabled on your account, send lab PDFs as lab_files on POST /visits or PUT /visits/{id}. See Lab test files.
  3. Assign practitioner_id (PUT /visits/{id} or at create).
  4. Wait for a Bespoke provider to approve. Partners cannot complete consults via the API. You may POST /visits/{id}/cancel only while the consult is pending.
  5. On approve, Bespoke creates the prescriptions for the visit.
  6. Poll GET /prescriptions/{submission_id}.

Consult photos (optional)

A consult may include clinical photos as HTTPS URLs: face (patient face) and photo_id (front of a photo ID). Partners host the files and send the URLs. Photos are optional and are not required to mark a consult completed. Photos stay inside Bespoke for provider review and are never sent to the pharmacy.

Attach photo URLs

curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/visits/consult-1001/images" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "face_url": "https://cdn.partner.example/patients/pt-1/face.jpg",
    "photo_id_url": "https://cdn.partner.example/patients/pt-1/id-front.jpg"
  }'
  • JSON fields face_url and photo_id_url are optional; send one or both.
  • Same fields may be included on POST /visits or PUT /visits/{id}.
  • Re-submit replaces the previous URL for that slot.
  • Resolve later with GET https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/images/face (or photo_id) — redirects to the partner URL.

Lab test files

A consult may include laboratory PDFs so the provider can review results before approving. This is off until a Bespoke admin turns on Lab test files for your account. You host each PDF and send the URL. Bespoke does not take a multipart upload for lab files.

Send lab_files on POST https://a-nirwan.bespoke-consults.designingit.co/v1/visits or PUT https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}. This is separate from a questionnaire answer such as lab_results_url.

{
  "lab_files": [
    {
      "url": "https://files.yourapp.com/labs/cmp-2026-10.pdf",
      "filename": "cmp-2026-10.pdf",
      "content_type": "application/pdf"
    }
  ]
}
  • url is required. It must be http or https and stay reachable for the provider.
  • Send filename ending in .pdf or content_type application/pdf. Only PDFs are accepted.
  • At most 10 files. Sending lab_files again replaces the list. [] clears it. Omit the field to leave stored files unchanged.
  • If the account setting is off, a non-empty list returns 403 lab_files_disabled.
  • A non-PDF returns 422 validation_failed.
  • GET /visits/{id} returns lab_files as filename, content_type, and url.
  • Lab files stay inside Bespoke for review. They are never sent to the pharmacy. They are optional on every consult except a lab review.

Lab review consults

For a consult where a physician only reviews labs, send questionnaire_type lab-review with questionnaire code lab-review and at least one PDF in lab_files. Fetch the questions from GET https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=lab-review. The physician's review comes back in patient_letter on consultation.approved, with any prescriptions they chose to write. No lab PDF returns 422 validation_failed. Lab test files turned off returns 403 lab_files_disabled. Lab review questionnaire →

Partner callbacks & Rx return

Outbound webhooks are documented on a dedicated page. They are disabled by default — a Bespoke admin enables them per partner, then the partner owner sets the callback URL in Partner dashboard → Integration → Webhooks.

Open webhook documentation →

Medical messaging

Patient ↔ provider messaging over the API. Disabled by default; a Bespoke admin enables it on the partner account. Two threads:

  • POST/GET https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/messages — messages on a consultation. {id} is the visit UUID or your external_visit_id.
  • POST/GET https://a-nirwan.bespoke-consults.designingit.co/v1/patients/{id}/messages — messages when there is no consultation. Create the patient first and include address.state. {id} is the patient UUID or your external_ref.

Both accept message (required unless you send files), attachments (up to 5 HTTPS image or PDF URLs), external_message_id, sender_type (patient or partner), sent_at, and metadata. A provider reply arrives as medical_message.created and includes the same attachments list. On a patient thread, visit_id is null — match the reply on external_patient_id.

Open messaging documentation →

Booked calls

Video and audio calls with a prescriber, for categories that require them (TRT today). Disabled by default; a Bespoke admin enables video visits on the partner account. A TRT consultation (questionnaire_type: "trt") then becomes sync on its own.

  • Only the patient picks the time: GET https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/availability lists the days with a free slot and returns the slots of the day the patient picks, then POST https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/appointment books it. The prescriber does not confirm.
  • You receive appointment.scheduled, and appointment.reminder about an hour before the call.
  • Reschedule and cancel through /visits/{id}/appointment/reschedule and /cancel.
  • The patient joins from your site: POST https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/video/patient-ticket gives a single-use ticket for the Bespoke call page in your iframe.

Open booked calls documentation → Joining a call →

Bespoke Prescription API — Developer Guide

Version 1.3

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.

External references

Partners should send stable external ids so Bespoke updates existing objects instead of creating disconnected snapshots. Typed aliases are accepted and returned alongside the legacy external_ref / brand_code names:

Partner field Stored as Entity
external_patient_id external_ref Patient
external_visit_id external_ref Visit / consult
external_product_id external_product_id Pharmacy medication (on Rx)
external_prescription_id external_ref Prescription submission
external_practitioner_id external_ref Provider
external_clinic_id external_ref Clinic
external_tenant_id brand_code Brand under the partner account

Recommended minimum: external_patient_id, external_visit_id, external_product_id. Look up a prescription with GET /prescriptions/{external_prescription_id}.

What the API does

You submit clinical consults (visits). When a Bespoke provider approves a consult, Bespoke creates the prescriptions for fulfilment. You then:

  1. Track those prescriptions with GET /prescriptions
  2. Receive status as the order is accepted, shipped, and delivered

You can also manage patients independently via /v1/patients (create/upsert, retrieve, update). Reuse the same external_ref on later consults so demographics are not duplicated.

One partner account can own many brands (sub-companies). Send brand_code on patients and prescriptions so Bespoke knows which brand every record belongs to. Omitted brand_code uses the partner's default brand. Hierarchy:

Partner → Brand → Patient → Visit → Prescription

You track progress by polling GET /prescriptions/{submission_id} and/or receiving outbound partner webhooks (configured per account).

Your system  →  POST /v1/patients              →  Bespoke patient record (optional first step)
Your system  →  POST /v1/visits                →  Bespoke consult (Open / stored `pending` + questionnaire)
Your system  ←  webhook consultation.created
Your system  →  PUT  /v1/visits/{id}           →  clinic / practitioner / metadata (no status change)
Prescriber   →  Completed or Declined           →  Bespoke consult decision
Your system  ←  webhook consultation.approved | declined
                 ↳ on approve: fills + doctor details + pharmacy-compatible e-script fields
Your system  ←  GET  /v1/prescriptions/{id}    ←  status updates (uuid or external_prescription_id)

Partner webhooks (callbacks)

Outbound webhooks are disabled by default. A Bespoke admin enables them on your account; you then configure the callback URL in Partner dashboard → Integration → Webhooks.

Full webhook contract (events, signatures, decline codes, pharmacy return):
/docs/webhooks

Booked video and audio calls send appointment.* events (including appointment.reminder an hour before each call) and consultation.call_requested: /docs/scheduling


Base URL

https://a-nirwan.bespoke-consults.designingit.co/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 the credential headers, plus the website the call is for:

Header Example Notes
X-Api-Key bsk_live_7Qd3xR2mVn8pLc4KfW1sTbYz Public key id
X-Api-Secret (your secret) Shown once when issued
X-Source-Domain clinic.example.com Your website hostname. Origin or Referer is accepted instead
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
No website and source IP registered 403 api_origin_required
Call is not from an allowed website or IP 403 origin_not_allowed

Never put credentials in a query string. Never call the API from a browser or mobile app — only from your backend. Send X-Source-Domain with the hostname of the website the call is for (clinic.example.com, or a full URL whose host is that name). Bespoke also accepts the host of Origin or Referer. The call is refused unless that hostname and the source IP are both on the allow-list an admin registered for your company. The source IP is your server's address. When the call reaches Bespoke through Cloudflare, that is the caller address Cloudflare reports, not a Cloudflare edge address. Allow-list your own server IP.

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

Write endpoints such as POST /visits and POST /patients upsert by your external ids, so retrying the same payload updates the existing record instead of creating a duplicate. Prescriptions are not created via the API.


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
GET /question-categories List intake categories and the questionnaires in each
GET /questions Questions for a category (?category=glp-1, optional ?questionnaire=)
GET /medications List pharmacy-supported medications
GET /products Alias of /medications (same response)
POST /patients Create or update a patient (upsert by external_ref)
GET /patients List patients for your account
GET /patients/{external_ref_or_patient_id} Retrieve a patient
PUT /patients/{external_ref_or_patient_id} Update an existing patient
POST /visits Create or update a clinical consult (starts Open, stored pending)
GET /visits List consults (?status= / ?patient_ref= / brand)
GET /visits/{external_ref_or_visit_id} Retrieve a consult
PUT /visits/{id} Update consult fields (clinic, practitioner, metadata) — not status
POST /visits/{id}/status Partners: withdraw an Open consult only while stored pending
POST /visits/{id}/complete Not available to partners (provider review only)
POST /visits/{id}/cancel Withdraw an Open consult only while stored pending
PUT / POST /visits/{id}/questionnaire Attach or replace medical questionnaire
POST /visits/{id}/images Attach optional photo URLs (face_url and/or photo_id_url)
GET /visits/{id}/images/{slot} Resolve one photo (face or photo_id) — redirects to partner URL
GET /visits/{id}/messages List medical messages on a consult (requires messaging enabled)
POST /visits/{id}/messages Post a medical message into the consult thread
GET /patients/{id}/messages List messages sent with no consultation
POST /patients/{id}/messages Post a message for a patient who has no consultation
GET /visits/{id}/availability Free days and one day's slots for a call consult (requires video visits enabled)
POST /visits/{id}/appointment Book a video or audio call on a slot from availability
GET /visits/{id}/appointment The booked call
POST /visits/{id}/appointment/reschedule Move the booked call to another slot
POST /visits/{id}/appointment/cancel Cancel the booked call
GET /visits/{id}/appointments Every call on the consult, cancelled ones included
POST /visits/{id}/video/patient-ticket Single-use ticket for the patient to join the call from your site
POST /clinics Create or update a clinic (upsert by external_ref)
GET /clinics List clinics
GET /clinics/{external_ref_or_clinic_id} Retrieve a clinic
POST /practitioners Create or update a practitioner (upsert by NPI / external_ref)
GET /practitioners List practitioners
GET /practitioners/{external_ref_or_practitioner_id} Retrieve a practitioner
GET /prescriptions/{submission_id_or_external_prescription_id} Retrieve one submission
GET /prescriptions List submissions (?patient_ref= / ?status= / ?brand_code=)
POST /visits/{id}/prescriptions/pause Hold remaining fills on a completed consult — only if pharmacy routing is activated (Bespoke channel)
POST /visits/{id}/prescriptions/activate Release held fills and send them to the pharmacy again
POST /prescriptions/{id}/pause Hold one fill on a completed consult
POST /prescriptions/{id}/activate Release one held fill

GET /ping

Smoke-test credentials (useful in deploy pipelines).

curl "https://a-nirwan.bespoke-consults.designingit.co/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

GET /question-categories

Lists treatment categories and the questionnaires inside each one. The catalog is the same for every partner.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/question-categories" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

Each category has slug, name, prescription_interval_days, and questionnaires (code, title, question_count).

prescription_interval_days is the default number of days between each prescription for that category. It is null when Bespoke has not set a gap. A partner can set a different gap with that category's rate; that partner gap is what the consult uses. Send the category slug as questionnaire_type on the visit. The GLP-1 category slug is glp-1. glp1 is also accepted. MICC/B-12 is micc-b12. Glutathione is glutathione. Peptides is peptides. Women's HRT is hrt. Men's TRT is trt (questionnaire code mens-trt). Lab review is lab-review (see Lab review consults). An unknown slug returns 422 validation_failed. The error includes the questionnaires docs URL.

GET /questions

Returns the questions for one category. category is required. When a category has more than one questionnaire (ED has sildenafil and tadalafil), pass questionnaire to return only that form.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=glp-1" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=micc-b12" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=glutathione" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

MICC/B-12 and glutathione collect height in feet and inches and weight in pounds, the same shape as GLP-1. The weight question sets config.show_bmi so you can display BMI. Neither form rejects a low BMI. MICC/B-12 always requires highest_weight and target_weight. Glutathione follow-ups, optional questions (config.optional), and the acknowledgment that every statement must be selected are documented at /docs/questionnaires.

Unknown category or questionnaire returns 404. A missing category returns 422 validation_failed.

Answer type values:

Type How to collect the answer
yes_no, single_select One value from options
multi_select One or more values from options
yes_no_with_answer One value from options; the matching option may include reveals_input
short_text, long_text, number, date, height, weight A typed value; options is empty
info Display only; do not collect an answer

When an option's reveals_input is set (for example choosing “Other” or “Yes”), show that extra field — usually a required text box labeled “Please specify”. A question with visible_when is shown only after an earlier answer matches (question_key, operator, value).

Send the collected answers on the visit as questionnaire.answers, using each question key as id. A submitted questionnaire must include every question that applies. Hidden follow-ups are not required. A partial answer list returns 422 validation_failed and is not stored. Conditional rules, the GLP-1 follow-ups, and the error shape are documented at /docs/questionnaires.

GET /medications

List medications the pharmacy network supports (and, when you have linked partner catalog products, only those linked items).

Query params: q (search name / strength / external id), brand_code / brand_id (limit to a brand's linked catalog).

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/medications?q=sema" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

200 OK

{
  "data": [
    {
      "medication_id": "3f2a1c8e-9b4d-4e6a-8f1c-2d7e9a0b5c4d",
      "external_product_id": "prx-sema-025",
      "name": "Semaglutide",
      "strength": "0.25mg",
      "ndc": null,
      "default_directions": "Inject 0.25 mg subcutaneously once weekly"
    }
  ]
}

Use external_product_id on consults when identifying a catalog product.

GET /products

Identical to GET /medications (path alias for partners that call the catalog “products”).


POST /patients

Create or update a standalone patient for your account. Identity is your external_ref (unique per account). Sending the same external_ref again updates demographics instead of creating a duplicate.

Returns 201 Created on first insert, 200 OK on update.

curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/patients" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
  "external_ref": "acme-patient-90210",
  "first_name": "Dana",
  "last_name": "Whitfield",
  "date_of_birth": "1986-04-11",
  "gender": "female",
  "email": "dana@example.com",
  "phone": "3055551234",
  "address": {
    "line1": "123 Main Street",
    "line2": "Apt 4",
    "city": "Miami",
    "state": "FL",
    "postal_code": "33101"
  }
}'

201 / 200 response

{
  "patient_id": "3f2a1c8e-9b4d-4e6a-8f1c-2d7e9a0b5c4d",
  "external_ref": "acme-patient-90210",
  "brand_id": "7c1e9a2b-4d5f-4a8e-9c3b-1f0e2d4a6b8c",
  "brand_code": "brand-a",
  "first_name": "Dana",
  "last_name": "Whitfield",
  "date_of_birth": "1986-04-11",
  "gender": "f",
  "email": "dana@example.com",
  "phone": "305-555-1234",
  "address": {
    "line1": "123 Main Street",
    "line2": "Apt 4",
    "city": "Miami",
    "state": "FL",
    "postal_code": "33101"
  },
  "created_at": "2026-08-12T11:14:03+00:00",
  "updated_at": "2026-08-12T11:14:03+00:00"
}
Field Type Required Rules
external_ref string yes Your patient id. Unique per brand. Max 191
brand_code string no Your brand/sub-company code (configured by Bespoke). Omitting uses the default brand
brand_id string (uuid) no Alternative to brand_code
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 / f (case-insensitive)
email string no Valid email
phone string yes US phone; punctuation is fine
address object yes Same address rules as prescriptions

Response patient_id is the Bespoke patient UUID. Use either patient_id or external_ref on later GET/PUT calls (add ?brand_code= when the same ref exists under multiple brands). Reuse the same external_ref + brand_code on later consults.


GET /patients/{external_ref_or_patient_id}

Retrieve one patient owned by your account. Path may be your external_ref or the Bespoke patient_id UUID.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/patients/acme-patient-90210" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

200 OK — same body as POST /patients. 404 not_found if unknown or owned by another account.


PUT /patients/{external_ref_or_patient_id}

Replace demographics for an existing patient. Path identifies the patient (external_ref or patient_id). Body uses the same fields as POST /patients except external_ref is optional (identity cannot be changed via the body).

200 OK on success. 404 if the patient does not exist for your account.

GET /patients

List patients for your account. Optional brand_code / brand_id to scope to one brand.


POST /visits

Open a clinical consult before sending a prescription. It starts Open (stored pending). Completing the consult is a separate explicit action — submitting an Rx never marks the visit Completed.

curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/visits" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "consult-1001",
    "patient_ref": "acme-patient-90210"
  }'
Field Type Required Rules
external_ref string no Your consult id; unique per brand
brand_code / brand_id string no Same brand rules as patients
patient_ref / patient_id string conditional Existing patient
patient object conditional Nested patient upsert if not referencing one
clinic_id / clinic uuid / object no Optional clinic
practitioner_id uuid no Accepted for compatibility, but does not override round-robin when this request creates a new consult
requested_product string no semaglutide (filled as Semaglutide / Glycine) or tirzepatide (filled as Tirzepatide / Glycine). A GLP-1 consult with no product defaults to Semaglutide / Glycine. For the weight-loss injectables, send micc-b12 (MICC + B-12) or glutathione (Glutathione). For peptides, send bpc-157, tb-500, mots-c, epitalon, or semax. For Women's HRT, send estrogen, progesterone, estradiol-vaginal-topical-cream, or Estrogen Patches. For Men's TRT, send testosterone-men-s-trt, enclomiphene-men-s-trt, hcg-men-s-trt, testosterone-cream-men-s-trt, or anastrozole-men-s-trt.
pharmacy_medication_id uuid no Optional catalog strength UUID
questionnaire_type string no Category slug from GET /question-categories, such as glp-1, micc-b12, glutathione, peptides, hrt, trt, ed, nad, derma, or lab-review. The GLP-1 category slug is glp-1. glp1 is also accepted. Women's HRT is hrt. Men's TRT is trt. A lab-review consult needs at least one PDF in lab_files. An unknown value returns 422 and a link to /docs/questionnaires.
questionnaire object no Full assessment answers (see below)
metadata object no Max 10 string values
is_test boolean no Marks a test consultation (see Test consultations). Default false. Requires Test consultations enabled on your account (ask your account manager).
lab_files array no Lab test PDFs for this consult (see Lab test files). Each item is { "url", "filename", "content_type" }. Requires Lab test files enabled on your account (ask your account manager). Omit the field to leave any files already stored unchanged.
consult_mode string no async (default, written review) or sync (needs a booked video or audio call). sync needs video visits enabled on your account and a category that offers calls (TRT today; a TRT consult is sync without sending this field). A sync consult never goes back to async. See Booked calls.

Medical questionnaire (clinical — Bespoke only): send on POST /visits or PUT /visits/{id}/questionnaire. Providers review answers in the admin Visits screen before completing the consult and issuing a prescription.

Never sent to the pharmacy network. The pharmacy receives only the resulting prescription/order fields. Do not put questionnaire content in directions, metadata on prescriptions, or any pharmacy payload.

Example questionnaire body (shape only — send every visible question; see /docs/questionnaires):

{
  "code": "weight",
  "type": "glp-1",
  "answers": [
    { "id": "weight_product", "question": "Choose your GLP-1 medication", "answer": "semaglutide" },
    { "id": "height", "question": "Your height", "answer": { "feet": 5, "inches": 10 } },
    { "id": "pounds", "question": "Your current weight", "answer": "220" }
  ]
}

Test consultations (is_test)

Use a test consultation when your integration needs to exercise the full live path (create consult → provider approval → prescription relay to the pharmacy network) without treating the order as a real patient shipment.

Account requirement: a Bespoke admin must enable Test consultations on your partner account. If it is off, is_test: true returns 403 with code test_consults_disabled. Sandbox keys (bsk_test_) may still require is_test: true on every consult when your account is configured for live pharmacy smoke tests — your account manager will confirm.

Payload: set is_test to true on POST /visits. Use obviously fictional patient demographics and addresses reserved for testing. Do not use test consults for load testing.

{
  "external_ref": "consult-smoke-2026-11-05",
  "is_test": true,
  "patient_ref": "test-patient-smoke-001",
  "questionnaire_type": "glp-1",
  "requested_product": "semaglutide",
  "questionnaire": {
    "code": "weight",
    "type": "glp-1",
    "answers": []
  }
}

Send a complete questionnaire in production tests (same rules as a real consult). The example above omits answers for brevity.

While the consult is Open and still stored pending, you may set or clear is_test with PUT /visits/{id}. After a provider has opened the consult (in_progress) or closed it, is_test cannot be changed.

Responses: GET /visits, GET /visits/{id}, and prescription payloads include is_test: true when the consult was marked as a test. Test consults are labelled in the Bespoke prescriber UI. They are excluded from partner dashboard order and revenue metrics.

What Bespoke does at the pharmacy

Test consults follow the same approval and relay flow as production consults. When Bespoke successfully relays a test fill and the submission reaches accepted on the live pharmacy connection, Bespoke schedules a pharmacy cancellation after a short delay (1 minute by default, configurable on the Bespoke deployment). Your environment must run queue workers so the delayed job runs; with PHARMACY_LIVE=false (fixture driver), no live cancellation is scheduled.

Bespoke may also attach a test marker on the pharmacy order when the network supports it; that marker is best-effort only — do not rely on the pharmacy to skip fulfilment without Bespoke’s cancel step.

Multi-fill plans: only the first fill is relayed to the pharmacy (at consult approval). Later ladder steps stay queued with their intended start dates. The scheduled fill-release job (bespoke:release-due-fills) does not send later fills for is_test consults — they exist for API/UI smoke tests only, not live refills.

What your application should do

Cancellation is tied to the prescription, not to POST /visits. Do not call pause immediately after creating the consult — prescriptions do not exist until a provider approves the consult.

Recommended sequence:

  1. POST /visits with is_test: true (and a complete questionnaire).
  2. Wait for provider approval (GET /visits/{id} → stored completed, or webhook consultation.approved).
  3. Poll GET /prescriptions or GET /prescriptions/{id} until status is accepted (relay is asynchronous; you may see queued → relaying first).
  4. Backup (recommended): call POST /visits/{id}/prescriptions/pause or POST /prescriptions/{id}/pause with a reason such as "Integration smoke test". This is idempotent if Bespoke already scheduled or completed auto-cancel. Poll until status is paused or cancelled, or until you receive webhook prescription.cancelled when webhooks are enabled.
POST /v1/visits                    → 201, is_test: true, Open / pending
                                     … provider approves …
GET  /v1/prescriptions/{id}        → status=queued | relaying
GET  /v1/prescriptions/{id}        → status=accepted
                                     … after ~1m (configurable) Bespoke requests pharmacy cancel …
POST /v1/prescriptions/{id}/pause    → optional backup if still accepted
GET  /v1/prescriptions/{id}        → status=paused | cancelled

403 pharmacy_channel_required — pause/activate requires pharmacy routing on your account (same rules as production).

Errors

Code When
test_consults_disabled is_test: true but Test consultations is not enabled on the account
test_consult_locked is_test change blocked after review has started

Withdraw an unused test consult while it is still Open and pending with POST /visits/{id}/cancel — that stops the consult before any prescription is created.

Lab test files (lab_files)

Send laboratory results with the consultation when the reviewing provider needs a panel, hormone result, or other lab PDF. This is separate from a questionnaire answer such as lab_results_url. lab_files is the consult-level list of files.

Account requirement: a Bespoke admin must enable Lab test files on your partner account. If it is off, a non-empty lab_files list returns 403 with code lab_files_disabled. Omitting lab_files is always allowed.

Where to send it: POST /visits and PUT /visits/{id}. There is no separate upload endpoint. You host the PDF and send an http or https URL. Bespoke does not accept multipart file uploads for lab files.

Field Type Required Rules
lab_files array no At most 10 files. Sending the field replaces the files already stored on that consult. [] clears them. Omitting the field leaves them unchanged.
lab_files[].url string (URL) yes Absolute http or https URL, max 2048 characters. The URL must stay reachable so the provider can open it.
lab_files[].filename string no Display name, max 255 characters. Use a name ending in .pdf when you omit content_type.
lab_files[].content_type string no Must be application/pdf when sent. Send filename or content_type so the file is known to be a PDF.

Only PDFs are accepted. An image, a missing type, or a non-PDF content_type returns 422 validation_failed and the consult is not updated with that list.

Lab files are clinical review data. They are never sent to the pharmacy network. They are not required on a consult, except a lab review, which needs at least one.

GET /visits and GET /visits/{id} return the stored list. Each item is filename, content_type, and url. The list is [] when none were sent.

{
  "external_ref": "consult-1001",
  "patient_ref": "acme-patient-90210",
  "questionnaire_type": "trt",
  "requested_product": "testosterone-men-s-trt",
  "lab_files": [
    {
      "url": "https://files.yourapp.com/labs/cmp-2026-10.pdf",
      "filename": "cmp-2026-10.pdf",
      "content_type": "application/pdf"
    }
  ]
}

To add or replace files after the consult exists:

curl -X PUT "https://a-nirwan.bespoke-consults.designingit.co/v1/visits/consult-1001" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "lab_files": [
      {
        "url": "https://files.yourapp.com/labs/hormones.pdf",
        "filename": "hormones.pdf",
        "content_type": "application/pdf"
      }
    ]
  }'

Errors

Code When
lab_files_disabled lab_files contains one or more files but Lab test files is not enabled on the account
validation_failed More than 10 files, a URL is missing or not http/https, or a file is not a PDF

MICC + B-12 and Glutathione consults

These two products are weight-loss intakes. They do not use the GLP-1 form, and a low BMI is accepted. The product must already be on the partner catalog.

  1. Load the questions before building the form.
curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=micc-b12" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=glutathione" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"
  1. Open the consult with POST /visits. Nest the patient, or send patient_id / patient_ref for a patient you already created. Set questionnaire_type, requested_product, and questionnaire.code from this table.
Product questionnaire_type questionnaire.code requested_product
MICC + B-12 micc-b12 micc-b12 micc-b12
Glutathione glutathione glutathione glutathione
  1. Put every question that applies in questionnaire.answers. Use each question key as id. Leave info questions out. Leave a follow-up out until its visible_when rule matches. A short list returns 422 validation_failed and the consult is not stored.

  2. Do not call complete. The consult stays open until a Bespoke provider approves it. Then poll GET /visits/{id} and GET /prescriptions/{id}. You do not send a prescription count or the gap between fills.

state on the patient address is a 2-letter code. postal_code is 5 digits. date_of_birth is YYYY-MM-DD. Height is { "feet": 5, "inches": 10 }. Weight answers (pounds, and on MICC + B-12 also highest_weight and target_weight) are pounds. face_url and photo_id_url are optional HTTPS links.

MICC + B-12 always requires highest_weight and target_weight. current_medications may be the exact string I am not currently taking any medications. A Yes that asks for a note is { "value": "Yes", "input": "the note" }.

Glutathione asks for date of birth, sex at birth, and the state where the patient is located inside the questionnaire as well as on the patient. pregnancy_status is required only when sex_at_birth is not Male. hoped_results, lab_results_url, and additional_information may be omitted. acknowledgments must include every statement. A lab file, when sent, is an HTTPS URL.

Question rules and follow-ups are on /docs/questionnaires.

Peptide consults

Peptides is one intake for every peptide product. It is not the GLP-1 form, and there is no BMI cutoff. Load the questions, then open the consult with the product the patient selected.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=peptides" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"
Product Form questionnaire_type questionnaire.code requested_product
BPC-157 Injectable peptides peptides bpc-157
TB-500 Injectable peptides peptides tb-500
MOTS-c Injectable peptides peptides mots-c
Epitalon Injectable peptides peptides epitalon
Semax Nasal peptides peptides semax

allergies may be the exact string I don't have any allergies. list_medications may be the exact string I don't take any medications. Choosing Other on primary_goal requires {"value": "Other", "input": "the goal"}. Leave info questions (intro_peptides, health_profile_intro, qualified_for_treatment) out of answers.

{
  "external_ref": "consult-peptides-1001",
  "patient_ref": "acme-patient-90210",
  "questionnaire_type": "peptides",
  "requested_product": "bpc-157",
  "questionnaire": {
    "code": "peptides",
    "type": "peptides",
    "answers": [
      { "id": "primary_goal", "answer": "Improve healing and recovery" },
      { "id": "height", "answer": { "feet": 5, "inches": 10 } },
      { "id": "pounds", "answer": "180" },
      { "id": "heart_conditions", "answer": ["No"] },
      { "id": "hormone_kidney_liver_conditions", "answer": ["No"] },
      { "id": "additional_conditions", "answer": ["No"] },
      { "id": "gastrointestinal_conditions", "answer": ["No"] },
      { "id": "cancer_diagnosis", "answer": "No" },
      { "id": "allergies", "answer": "I don't have any allergies" },
      { "id": "take_medications", "answer": ["None of the above"] },
      { "id": "list_medications", "answer": "I don't take any medications" },
      { "id": "conditions_already_reported", "answer": "No" }
    ]
  }
}

Women's HRT consults

Women's HRT is one intake. It is not the GLP-1 form, and there is no BMI cutoff. The category slug is hrt. The questionnaire code is hrt. women-hrt is a product-catalog slug, not a questionnaire category.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=hrt" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

Open the consult with POST /visits. Set questionnaire_type to hrt, questionnaire.code to hrt, and requested_product to the hormone the patient selected.

Product questionnaire_type questionnaire.code requested_product
Estrogen Tablets hrt hrt estrogen
Progesterone Capsules hrt hrt progesterone
Estradiol Vaginal Topical Cream hrt hrt estradiol-vaginal-topical-cream
Estrogen Patches hrt hrt Estrogen Patches

Send Estrogen Patches by that name. The patches share a family with other estradiol items, and the name keeps the consult on the patches.

symptoms is one or more option values. height is { "feet": 5, "inches": 6 }. pounds is weight in pounds. medications and concerns use Yes and No. The other yes/no questions use yes and no.

Show birth_control_method only when iud is yes. Show medical_conditions_list only when medical_conditions is not None. Show hormone_therapy_previous only when hormone_therapy is yes. Leave those answers out until the rule matches.

{
  "external_ref": "consult-hrt-1001",
  "patient_ref": "acme-patient-90210",
  "questionnaire_type": "hrt",
  "requested_product": "estrogen",
  "questionnaire": {
    "code": "hrt",
    "type": "hrt",
    "answers": [
      { "id": "symptoms", "answer": ["Hot Flashes", "Night Sweats"] },
      { "id": "lactating", "answer": "no" },
      { "id": "trying_pregnant", "answer": "no" },
      { "id": "birth_control", "answer": "yes" },
      { "id": "iud", "answer": "no" },
      { "id": "doctor_visit", "answer": "yes" },
      { "id": "medical_conditions", "answer": "None" },
      { "id": "hysterectomy", "answer": "no" },
      { "id": "oophorectomy", "answer": "no" },
      { "id": "allergies", "answer": "no" },
      { "id": "hormone_therapy", "answer": "no" },
      { "id": "medications", "answer": "No" },
      { "id": "concerns", "answer": "No" },
      { "id": "height", "answer": { "feet": 5, "inches": 6 } },
      { "id": "pounds", "answer": "150" }
    ]
  }
}

Men's TRT consults

Men's TRT is one intake. It is not the GLP-1 form. The category slug is trt. The questionnaire code is mens-trt. mens-trt is not the category slug.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=trt" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

Open the consult with POST /visits. Set questionnaire_type to trt, questionnaire.code to mens-trt, and requested_product to the medicine the patient selected.

Product questionnaire_type questionnaire.code requested_product
Testosterone Cypionate trt mens-trt testosterone-men-s-trt
Enclomiphene trt mens-trt enclomiphene-men-s-trt
HCG trt mens-trt hcg-men-s-trt
Testosterone Cream trt mens-trt testosterone-cream-men-s-trt
Anastrozole trt mens-trt anastrozole-men-s-trt

symptoms is one or more option values. trt_intro is display only. Leave it out of answers. recent_labs uses Yes and No. Show recent_labs_value only when recent_labs is Yes, and send the most recent total testosterone and the approximate date.

{
  "external_ref": "consult-trt-1001",
  "patient_ref": "acme-patient-90210",
  "questionnaire_type": "trt",
  "requested_product": "testosterone-men-s-trt",
  "questionnaire": {
    "code": "mens-trt",
    "type": "trt",
    "answers": [
      { "id": "symptoms", "answer": ["Low Energy", "Low Sex Drive"] },
      { "id": "symptom_duration", "answer": "1 - 3 years" },
      { "id": "trt_familiarity", "answer": "I know some, but want to go deep" },
      { "id": "trt_goal", "answer": "More energy, focus, and confidence" },
      { "id": "recent_labs", "answer": "No" },
      { "id": "start_timeline", "answer": "Immediately" }
    ]
  }
}

Lab review consults

A lab review is a consult where a physician reviews the patient's lab results and writes back to them. The category slug is lab-review. The questionnaire code is lab-review.

Account requirement: lab reviews need Lab test files enabled on your account (see Lab test files). If it is off, a lab-review consult returns 403 lab_files_disabled.

curl "https://a-nirwan.bespoke-consults.designingit.co/v1/questions?category=lab-review" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

All five questions are free text (long_text).

id Required Max length What to send
review_goals yes 5000 What the patient wants from the review, in their own words
review_questions no 5000 The patient's questions for the physician about the results
current_medications yes 5000 Medications and supplements with doses. Send I am not currently taking any medications when there are none
allergies yes 5000 Allergies to medications, foods, or other substances. Send I don't have any allergies when there are none
lab_results_text no 20000 Results typed out, in addition to the PDFs

At least one lab PDF is required. Send it in lab_files on POST /visits. A lab-review consult with no lab file returns 422 validation_failed with an error on lab_files. On PUT /visits/{id}, you may leave lab_files out to keep the stored files, but you cannot clear them with [].

requested_product is not needed. A lab review starts with no prescription.

{
  "external_ref": "consult-lab-1001",
  "patient_ref": "acme-patient-90210",
  "questionnaire_type": "lab-review",
  "questionnaire": {
    "code": "lab-review",
    "type": "lab-review",
    "answers": [
      { "id": "review_goals", "answer": "I want to know if my thyroid and iron levels explain my tiredness." },
      { "id": "review_questions", "answer": "Is my ferritin too low? Should I retest?" },
      { "id": "current_medications", "answer": "Vitamin D 2000 IU daily" },
      { "id": "allergies", "answer": "I don't have any allergies" },
      { "id": "lab_results_text", "answer": "TSH 3.9 mIU/L, ferritin 18 ng/mL, vitamin D 28 ng/mL" }
    ]
  },
  "lab_files": [
    {
      "url": "https://files.yourapp.com/labs/panel-2026-11.pdf",
      "filename": "panel-2026-11.pdf",
      "content_type": "application/pdf"
    }
  ]
}

What comes back. The physician writes the review in the patient letter and completes the consult. The consultation.approved webhook carries:

  • data.patient_letter: the physician's review and recommendations, as plain text. It is also on GET /visits/{id}.
  • data.prescriptions: [] when the physician did not prescribe. The physician may prescribe any product your pharmacy stocks. Those fills arrive here the same way as on any other consult.

Lab reviews are not billed for now. A prescription issued from a lab review is handled the same way as on any other consult.

Errors

Code When
lab_files_disabled questionnaire_type is lab-review but Lab test files is not enabled on the account
validation_failed No lab PDF in lab_files, a required answer is missing, or an answer is too long

Sample requests

Full POST /visits bodies for MICC + B-12 and Glutathione. Each body includes the patient, the product, and every answer that applies.

Consultations have three statuses. The JSON status field is the stored value:

Status Meaning Stored status
Open Awaiting or in review pending or in_progress
Completed Provider finished the consult completed
Declined Provider declined declined

Opening the consult in /prescriber may store in_progress; the status stays Open. needs_information, cancelled, and abandoned are not product statuses.

Partners may withdraw an Open consult only while it is still stored pending (cancelled on POST /visits/{id}/status). That is a partner API action, not a fourth consultation status.

PUT /visits/{id}

Update consult fields without changing status: clinic_id / nested clinic, practitioner_id, metadata, lab_files (see Lab test files), is_test (only while Open and stored pending). There is no notes field. A notes key on create or update is ignored and is not returned. Clinical comments belong in questionnaire answers. Status changes use the endpoints below. practitioner_id, metadata. There is no notes field. A notes key on create or update is ignored and is not returned. Clinical comments belong in questionnaire answers. consult_mode: "sync" turns an open async consult into a call consult; "async" on a sync consult returns 422. practitioner_id returns 422 while a call is booked: reschedule or cancel the call first. Status changes use the endpoints below.

POST /visits/{id}/status

Partners may only withdraw an Open consult while it is still stored pending:

{ "status": "cancelled", "reason": "Patient withdrew" }

Partners cannot set Completed or Declined. A Bespoke provider reviews and completes or declines in the consult UI. After a provider has opened the consult (stored in_progress) or closed it, partners cannot withdraw it.

Terminal states cannot be reopened. Creating a prescription never changes visit status.

POST /visits/{id}/complete

Not available to partners. Completing (approving) a consult is a Bespoke provider action.

POST /visits/{id}/cancel

Shortcut for { "status": "cancelled" }. Works only while the consult is Open and still stored pending. Optional body: { "reason": "…" }.

PUT /visits/{id}/questionnaire

Attach or replace the medical assessment on an existing consult (same questionnaire / questionnaire_type fields as create). POST to the same path is accepted as an alias. Clinical data stays on the visit for provider review and is never relayed to the pharmacy.

POST /visits/{id}/images

Attach optional clinical photos as partner-hosted HTTPS URLs. Send one or both:

Field Type Required Rules
face_url string (URL) no Absolute http/https URL, max 2048 chars
photo_id_url string (URL) no Absolute http/https URL — front of government/photo ID

Aliases also accepted: face / photo_id, or nested under images (images.face, images.photo_id, …). The same fields may be sent on POST /visits or PUT /visits/{id}.

Photos are not required to complete a consult. Re-submitting replaces the previous URL for that slot. Photos are clinical review data only — never sent to the pharmacy network.

curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/visits/consult-1001/images" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "face_url": "https://cdn.partner.example/patients/pt-1/face.jpg",
    "photo_id_url": "https://cdn.partner.example/patients/pt-1/id-front.jpg"
  }'

Visit responses include:

{
  "images_complete": true,
  "images": {
    "face": {
      "slot": "face",
      "url": "https://cdn.partner.example/patients/pt-1/face.jpg",
      "uploaded_at": "2026-08-28T12:00:00+00:00"
    },
    "photo_id": {
      "slot": "photo_id",
      "url": "https://cdn.partner.example/patients/pt-1/id-front.jpg",
      "uploaded_at": "2026-08-28T12:00:00+00:00"
    }
  }
}

GET /visits/{id}/images/{slot}

Resolve one photo (face or photo_id) using the same API credentials. Responds with a redirect to the partner-hosted source_url.

GET /visits/{id}/messages · POST /visits/{id}/messages

Medical messaging thread for one consult (patient ↔ provider). Requires medical messaging enabled on your partner account (admin toggle; off by default).

POST body:

Field Required Notes
message yes, unless attachments is present Body text (alias: body). Max 10,000 characters.
attachments no Up to 5 files. Each item is { "url", "filename", "content_type" }. url is required and must be http or https. Allowed types: JPEG, PNG, GIF, WebP, PDF. Send filename or content_type so the type is known. A message can be files only.
external_message_id no Your idempotency id. A repeat returns the original message and does not add more files.
sender_type no patient (default) or partner
sent_at no ISO-8601; defaults to now
metadata no Small JSON object

Provider replies in the Bespoke Message center; when webhooks are enabled you receive medical_message.created. Full contract: /docs/messaging.

GET /patients/{id}/messages · POST /patients/{id}/messages

Message a patient when there is no consultation. Create the patient with POST /patients first (include address.state, a two-letter US code). {id} is the patient UUID or your external_ref. Pass ?brand_code= when that external_ref exists on more than one brand.

The POST body is the same as a consult message:

Field Required Notes
message yes, unless attachments is present Body text (alias: body). Max 10,000 characters.
attachments no Same file list as a consult message: up to 5 JPEG, PNG, GIF, WebP, or PDF URLs.
external_message_id no Your idempotency id. Unique on the account, including ids already used on a visit. A repeat returns the original message and does not add more files.
sender_type no patient (default) or partner
sent_at no ISO-8601; defaults to now
metadata no Small JSON object

201 response is one message object (message_id, direction, sender_type, body, attachments, sent_at, …). attachments is always an array of { "filename", "content_type", "url" }. It does not include a visit id.

GET returns only messages posted on the patient. Consult messages stay on GET /visits/{id}/messages.

{
  "patient_id": "3f2a1c8e-9b4d-4e6a-8f1c-2d7e9a0b5c4d",
  "external_patient_id": "acme-patient-90210",
  "visit_id": null,
  "external_visit_id": null,
  "messages": []
}
Status Meaning
403 messaging_disabled Medical messaging is off for this account.
404 No patient with that id on this account.
422 validation_failed message is missing and there are no attachments, a file type is not an image or PDF, or a field is invalid.

A prescriber licensed in the patient’s state can open the thread. The platform default provider can open every thread. If state is missing, only the default provider sees it.

When they reply, you receive medical_message.created. visit_id, external_visit_id, practitioner_id, and external_practitioner_id are null. Match the reply on patient_id or external_patient_id. sender_name is the prescriber. Full walkthrough: /docs/messaging.

GET /visits / GET /visits/{id}

List (filter ?status= / ?patient_ref= / brand) or retrieve one consult. ?status= uses the stored value (pending, in_progress, completed, declined). Product statuses are Open (pending / in_progress), Completed, and Declined. The visit response includes questionnaire_type, questionnaire, questionnaire_submitted_at when present, and is_test when the consult is a test consultation. The visit response includes questionnaire_type, questionnaire, and questionnaire_submitted_at when present. It also includes consult_mode and appointment (the booked call, or null).

Booked calls (video and audio)

A sync consult needs a live call with a prescriber. Requires video visits enabled on your partner account (admin toggle; off by default). Otherwise these endpoints return 403 video_visits_disabled. Booking never changes the consult status.

Calls are for categories that require them: TRT today. With video visits on, a consult with questionnaire_type: "trt" is sync on its own; consult_mode: "sync" for another category (for example glp-1) returns 422 on consult_mode. A consult with no category may still ask for a call.

A sync consult keeps a category that offers calls: changing questionnaire_type to one without calls, on POST /visits with the same external_ref or on /visits/{id}/questionnaire, returns 422 on questionnaire_type. While a call is booked, the consult also keeps its prescriber (422 on practitioner_id) and its patient (422 on patient, for example a repeated POST /visits with another patient): cancel the call first.

Flow: you ask for availability → show the patient the free days → the patient picks a day and a time → you book it → appointment.scheduled → appointment.reminder about an hour before. Only the patient picks the time: the prescriber does not book, move, or confirm calls; a booked call simply appears in their calendar. Bespoke sends no email or SMS to patients, so you notify and remind the patient.

Availability offers every day with a free slot, from today through the next 14 days, in the patient's time zone. Today counts once a slot is at least 2 hours away; a full day is left out. Weekends count only when a prescriber works them. Slot ids come for one day at a time: the nearest day, or the one you send in date. Calls are 5 minutes, one every 10 minutes (a 5-minute break after each). The slots are the hours of the consult's own prescriber, the one assigned when the consult was created (practitioner_id).

Changed on November 12, 2026: the slots used to come from every prescriber licensed in the patient's state, and booking could move the consult to the slot's prescriber. Now only the consult's prescriber offers slots, and booking never changes practitioner_id.

Changed on November 10, 2026: availability used to return only the nearest day after today. It now adds days and takes date, and offers today. The other fields are unchanged, so code that shows the one day it gets still works.

Method and path Purpose
GET /visits/{id}/availability The free days and one day's slots. Query: timezone (IANA, the patient's zone; without it, the prescriber's zone decides the day) and optional date (YYYY-MM-DD in that zone; without it, the nearest free day). Response: days (every free day, no slot ids), date, timezone, and slots of that date. A full, past, or too distant date gives empty slots; a badly written one 422 on date. With nothing free in the next 14 days, days and slots are empty and date is null. Each slot has an opaque slot id valid for 60 minutes. No prescriber details. from, to, and prescriber are rejected with 422.
POST /visits/{id}/appointment Book: slot and modality (video or audio) required; patient_timezone, patient_location_state optional. 201; the same slot again returns 200 with the same call. 409 slot_unavailable if it was taken.
GET /visits/{id}/appointment The booked call. 404 no_appointment if none.
POST /visits/{id}/appointment/reschedule New slot from availability (which then lists only the same prescriber's days and slots); optional modality. The same time with another modality returns 422 on modality.
POST /visits/{id}/appointment/cancel reason: patient_request or other; optional note (kept for Bespoke staff).
GET /visits/{id}/appointments Every call on the consult, cancelled ones included, sorted by call start time (same time: in booking order). rescheduled_from_id links each call to the one it replaced.

Booking keeps the consult's practitioner_id. booked_by is partner or admin (Bespoke staff on the patient's behalf). Every change, including system cancellations (prescriber time off, consult closed), sends an appointment.* webhook, and each booked call sends one appointment.reminder 60 minutes before it starts. Full contract: /docs/scheduling.

Joining the call

The patient joins from your website and stays on your page. You add one script and one backend route; the script shows the Bespoke call page inside your page and hands it a single-use ticket. Full guide, with backend examples in PHP and Node: /docs/joining-a-call.

Method and path Purpose
POST /visits/{id}/video/patient-ticket A ticket for the booked call, from your backend. No body. 201: ticket (single use, 5 minutes), expires_at, visit_id, appointment_id, modality, join_window (opens_at, closes_at), call_page_url. Errors: 403 video_visits_disabled, 404 not_found / no_appointment, 409 join_window_closed (with join_window), 409 not_a_video_consultation, 422 when the consultation is closed.
<button id="join">Join your visit</button>
<div id="call"></div>

<script src="https://a-nirwan.bespoke-consults.designingit.co/video/embed.js"></script>
<script>
  async function getTicket() {
    const response = await fetch('/your-backend/call-ticket', { method: 'POST' });
    if (!response.ok) throw new Error('No ticket');
    return (await response.json()).ticket;
  }

  document.getElementById('join').addEventListener('click', () => {
    BespokeCall.mount(document.getElementById('call'), {
      callPageUrl: 'YOUR_CALL_PAGE_URL',
      getTicket,
      onEnd: () => { /* show the patient your next step */ },
    });
  });
</script>
  • /your-backend/call-ticket is your own route: it calls the ticket endpoint with your API key, which stays on your server. Take the consultation from the patient's login, never from the browser. call_page_url is the same for every call on your account, so keep it in your settings.
  • BespokeCall.mount(element, options) returns { destroy() }. Options: callPageUrl, getTicket() (a promise of the ticket, as a string), and optional theme, onState(state), onError(code), onEnd(). Give the element a height, at least 600px. onError codes: ticket_failed, page_not_loaded, invalid_ticket, network, rate_limited, unavailable.
  • The join window runs from 10 minutes before the start to 30 minutes after the booked end. A call entered in time is not cut off then: it stays open 60 more minutes, and the patient can still get a ticket for it.
  • The call shows only on websites Bespoke registered for your account, over https://. The ticket never goes in a URL.

Without the script: an iframe that loads call_page_url with allow="camera; microphone". The page sends bespoke-call:ready; you answer with postMessage { type: "bespoke-call:start", ticket, theme? } to the page's origin, with a new ticket each time it asks, or { type: "bespoke-call:no-ticket" } if you could not get one. It reports bespoke-call:state (for example waiting, in_call, ended) and bespoke-call:error (invalid_ticket, network, rate_limited, unavailable). No patient data in either.


POST /clinics

Create or update a clinic under your brand. Upserts by external_ref when provided.

Field Type Required Rules
brand_code / brand_id string no Same brand rules as patients
external_ref string no Your clinic id; unique per brand
name string yes
phone string no
address object yes Same address rules as patients

Response includes clinic_id, external_ref, brand fields, name, phone, address.

GET /clinics / GET /clinics/{id}

List or retrieve clinics for your account. Lookup by clinic_id UUID or external_ref.


POST /practitioners

Create or update a provider / practitioner. Upserts by npi under the brand. Which consults they can review depends on state licenses, not on a clinic.

Field Type Required Rules
brand_code / brand_id string no
external_ref string no Your external provider id
npi string yes Exactly 10 digits; unique per brand
first_name / last_name string yes
phone string yes
address object no Mailing address
licenses array no State licenses (see below)
licenses[].state string yes 2-letter US state
licenses[].license_number string yes
licenses[].expires_at date no YYYY-MM-DD
licenses[].is_active bool no Default true

Response includes practitioner_id (internal public uuid), external_ref, NPI, name, phone, address, and licenses.

New consults are assigned automatically by the global state round-robin across every active provider with a valid license for the patient's state, regardless of company association. A submitted practitioner_id does not override creation-time rotation; use PUT /visits/{id} for an explicit reassignment. Completing a consult requires a provider. Prescriptions linked to a visit always use that visit's assigned practitioner, even if the prescription payload contains a different prescriber.

GET /practitioners / GET /practitioners/{id}

List or retrieve practitioners. Lookup by practitioner_id UUID or external_ref.


Prescriptions

Partners cannot create prescriptions via the API. When a Bespoke provider approves a consult, Bespoke creates the fills for that visit (count is set on your account). Use GET to list and poll those submissions.

GET /prescriptions/{submission_id_or_external_prescription_id}

Retrieve one submission by Bespoke submission_id (UUID) or your external_prescription_id (stored as external_ref). Optional ?brand_code= / ?brand_id= when looking up by external id under a multi-brand account.

Fetch one submission by Bespoke submission_id (UUID) or your external_prescription_id.

curl "https://a-nirwan.bespoke-consults.designingit.co/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",
  "is_test": false,
  "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 understand why fulfilment failed.

404 — unknown id, or a submission that belongs to another account / outside retention.

Pause and activate fills (pharmacy routing required)

These endpoints work only when pharmacy routing is activated on your account so prescriptions go through the Bespoke pharmacy channel (admin: Route to pharmacy network).

If routing is off, or your account is set to return prescriptions to you (partner_return), pause and activate are not available. Those calls return 403 pharmacy_channel_required. Ask your account manager to turn on pharmacy routing if you need this.

The consult must already be completed. Shipped and delivered fills cannot be held.

POST /visits/{id}/prescriptions/pause

Hold remaining fills on a completed consult (pharmacy routing must be on).

Queued fills are held in Bespoke and are not relayed. Accepted (not yet shipped) fills are cancelled at the pharmacy and marked paused. Shipped and delivered fills are skipped.

For is_test consults, Bespoke also schedules pharmacy cancellation (default 1 minute after accepted on the live pharmacy connection). You may still call pause sooner as a backup (see Test consultations).

curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/visits/VISIT_ID/prescriptions/pause" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"reason": "Patient asked to hold remaining fills"}'

Optional prescription_id (UUID or your external_prescription_id) limits the call to one fill. reason is optional.

200

{
  "visit_id": "…",
  "action": "paused",
  "updated": 2,
  "unchanged": 0,
  "skipped": 1,
  "prescriptions": [
    { "submission_id": "…", "status": "paused" }
  ]
}

403 pharmacy_channel_required — pharmacy routing is not activated for the Bespoke pharmacy channel (account is partner_return, or fills are not sent through us).

422 — consult is not completed, or every fill has already shipped / been delivered.

POST /visits/{id}/prescriptions/activate

Release paused fills. They return to queued and are sent to the pharmacy again. Fills that were accepted (and therefore cancelled at the pharmacy when paused) are submitted as a new pharmacy order.

Same optional prescription_id as pause.

curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/visits/VISIT_ID/prescriptions/activate" \
  -H "X-Api-Key: $BESPOKE_KEY" \
  -H "X-Api-Secret: $BESPOKE_SECRET" \
  -H "Accept: application/json"

POST /prescriptions/{id}/pause and POST /prescriptions/{id}/activate do the same thing for a single fill and return that submission.


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://a-nirwan.bespoke-consults.designingit.co/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
                 (pharmacy or 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 the pharmacy or 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 accepted within seconds when the queue is healthy.
  • shipped typically follows within a business day once fulfilment starts.
  • After failed, do not keep polling the same id for recovery.

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
403 api_origin_required No website and source IP are registered Ask Bespoke to register them
403 origin_not_allowed Domain or source IP is not on the allow-list Send X-Source-Domain for an allowed site, from an allowed IP
403 pharmacy_channel_required Pause/activate only for Bespoke pharmacy routing Use your own pharmacy tools, or ask to switch routing
403 lab_files_disabled lab_files sent, or a lab-review consult opened, but Lab test files is off for the account Ask your account manager to enable lab test files, or omit lab_files
403 video_visits_disabled Booked calls are not enabled for your account Ask Bespoke to enable video visits
404 not_found No such submission for your account Check the id
404 no_appointment The consult has no booked call Book one first
409 slot_unavailable The chosen call time was taken Fetch availability again
409 join_window_closed The call cannot be joined yet, or any more (the body has join_window) Ask again inside join_window
409 not_a_video_consultation The consult is no longer sync, or its category has no calls Do not show the call
410 endpoint_gone This endpoint is no longer available Stop calling it; use a documented endpoint
422 validation_failed One or more fields invalid Fix fields and retry
429 rate_limited Too many requests Honour Retry-After
500 server_error Unexpected failure on our side Retry later
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:

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 for fulfilment failures.


  1. Call GET /ping from your deploy pipeline with sandbox credentials.
  2. Optionally GET /products (or /medications) to pick catalog ids.
  3. Optionally POST /patients once per person (upsert by your external_ref).
  4. POST /visits with questionnaire (photo URLs optional; lab_files when Lab test files is enabled); later PUT /visits/{id}.
  5. For a call consult (TRT with video visits enabled): GET /visits/{id}/availability, the patient picks a time, POST /visits/{id}/appointment; handle appointment.* webhooks; when the call starts, POST /visits/{id}/video/patient-ticket and show the call on your page (see Booked calls).
  6. A Bespoke provider reviews and approves the consult. Bespoke then creates the prescriptions.
  7. Poll GET /prescriptions (filter by patient_ref) or GET /prescriptions/{id} until terminal status.
  8. If failed, show error.message to your operators.

Minimal happy-path sequence

GET  /v1/ping
GET  /v1/products               → catalog (optional)
POST /v1/patients               → 201 (optional; upsert by external_ref)
POST /v1/visits                 → 201 Open / stored pending (+ questionnaire; photo URLs optional)
                                  provider approves consult
GET  /v1/prescriptions          → fills created on approve
GET  /v1/prescriptions/{id}     → status=relaying | accepted | …
GET  /v1/prescriptions/{id}     → status=shipped (+ tracking)
GET  /v1/prescriptions/{id}     → status=delivered

Live pharmacy smoke test (test consultation)

Use only with Test consultations enabled and fictional test patients — not for production patient traffic.

POST /v1/visits                 → 201, is_test: true
                                  provider approves consult
GET  /v1/prescriptions          → fills created on approve, is_test: true
GET  /v1/prescriptions/{id}     → status=accepted
                                  after delay, Bespoke requests pharmacy cancel (queue workers required)
POST /v1/prescriptions/{id}/pause → optional backup
GET  /v1/prescriptions/{id}     → status=paused | cancelled

Partner dashboard

In addition to the API, your team can sign in to the partner dashboard to:

  • View Patients, Visits / consults, Assessments (questionnaires), and Prescriptions
  • Browse Products, Brands, Clinics, and Practitioners
  • Inspect failure messages and submission status history
  • 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
Live pharmacy smoke test Use is_test: true when Test consultations is enabled Same; requires admin toggle

Use sandbox credentials until your payloads consistently reach accepted. Then switch to live keys and the production base URL your account manager provides.

To exercise real pharmacy relay without a production patient order, use a live key (or as directed by your account manager), enable Test consultations, send is_test: true, and follow Test consultations.


Integration checklist

  • Credentials stored in a secret manager (never in source control)
  • All calls made server-to-server over HTTPS
  • Consults submitted with questionnaire (photo URLs optional)
  • For call consults: availability → book flow built, appointment.* webhooks handled, call embedded on your page (see Booked calls)
  • X-Request-Id captured in your logs
  • Retries with backoff on 429, 500, and 503
  • Polling capped (≤ 1/minute per submission)
  • Tested end-to-end against sandbox before go-live
  • Live pharmacy smoke tests use is_test: true, fictional patients, and pause after accepted when Test consultations is enabled

Support

Contact your account manager, or email support@bespoke.example.

Always include:

  • submission_id and/or request_id (X-Request-Id)
  • Approximate time of the request (UTC)
  • Whether you are on sandbox or live