Medical messaging API
Patient ↔ provider messaging for the medical channel only. Your portal keeps customer/support questions on your side; treatment questions come to Bespoke over this API (not an iframe).
medical_message.created webhook when webhooks are also enabled.
Overview
- One message thread per consultation, and a separate thread on the patient when there is no consultation yet
- You post messages from your website/app with a consult id, or with only a patient id
- Prescribers read and reply in Bespoke’s Messages inbox
- Replies are pushed back to your callback URL
- History stays on the patient, and on the consultation when one exists
Enablement
| Who | What |
|---|---|
| Bespoke admin | Toggle Enable medical messaging API on the partner (off by default). |
| Bespoke admin | Optionally enable outbound webhooks so provider replies reach your URL. |
| Partner owner | Set callback URL under Integration → Webhooks (subscribe to medical_message.created). |
If messaging is disabled, POST/GET …/messages returns 403 messaging_disabled.
Flow
On a consultation
- Patient asks a treatment question in your app.
- Your backend
POSTs to Bespoke with the visit id (UUID or yourexternal_visit_id) and the message. - The message appears on that consult for the assigned provider.
- The provider replies in Bespoke.
- You receive
medical_message.createdand show the reply in your UI.
With no consultation
- Create or update the patient with
POST https://a-ferreira.bespoke-consults.designingit.co/v1/patients. Includestate(two-letter US code) so a licensed prescriber can open the thread. - Your backend
POSTs to/patients/{id}/messages.{id}is the Bespoke patient UUID or yourexternal_ref. Do not create a visit. - The thread appears in the prescriber Messages inbox as a direct message with no consultation.
- A prescriber replies in Bespoke.
- You receive
medical_message.createdwithvisit_idandexternal_visit_idset tonull. Show that reply in the same patient chat.
A prescriber licensed in the patient’s state can open a no-consult thread.
The platform’s default provider can open every thread.
If state is missing, only the default provider sees it.
Endpoints
GET https://a-ferreira.bespoke-consults.designingit.co/v1/visits/{visit_id_or_external_ref}/messages
POST https://a-ferreira.bespoke-consults.designingit.co/v1/visits/{visit_id_or_external_ref}/messages
GET https://a-ferreira.bespoke-consults.designingit.co/v1/patients/{patient_id_or_external_ref}/messages
POST https://a-ferreira.bespoke-consults.designingit.co/v1/patients/{patient_id_or_external_ref}/messages
Same auth headers as the rest of the API: X-Api-Key, X-Api-Secret.
Use the visit routes when the message belongs to a consultation.
Use the patient routes when there is no consultation.
GET /patients/…/messages returns only messages that were posted on the patient.
Messages posted on a visit stay on GET /visits/…/messages.
Post a message (partner → Bespoke)
curl -X POST "https://a-ferreira.bespoke-consults.designingit.co/v1/visits/javier-consult-1001/messages" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"message": "Patient asks about injection site redness after week 2.",
"external_message_id": "msg-7781",
"sender_type": "patient",
"sent_at": "2026-08-30T15:04:00Z",
"metadata": { "source": "patient_app" }
}'
| Field | Required | Notes |
|---|---|---|
message | yes, unless files are attached | Body text (alias: body). Max 10,000 characters. |
attachments | no | Up to 5 images or PDFs. See Images and PDFs. |
external_message_id | no | Your idempotency key — re-posts return the same message and do not add more files |
sender_type | no | patient (default) or partner |
sent_at | no | ISO-8601; defaults to now |
metadata | no | Small JSON object for your own keys |
Response 201:
{
"message_id": "…",
"external_message_id": "msg-7781",
"direction": "inbound",
"sender_type": "patient",
"sender_name": "Patient",
"body": "Patient asks about injection site redness after week 2.",
"attachments": [],
"sent_at": "2026-08-30T15:04:00+00:00",
"metadata": { "source": "patient_app" },
"created_at": "…"
}
Images and PDFs
A message can include up to 5 files, with or without text. Send each file as an HTTPS URL
you host. We do not accept a multipart upload on these endpoints. Allowed types are
JPEG, PNG, GIF, WebP, and PDF. Include filename or content_type
so the type is known. If you send both, they must agree.
curl -X POST "https://a-ferreira.bespoke-consults.designingit.co/v1/patients/your-pt-55/messages" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"message": "Lab panel from this morning.",
"external_message_id": "msg-lab-1",
"sender_type": "patient",
"attachments": [
{
"url": "https://files.yourapp.com/labs/panel.pdf",
"filename": "panel.pdf",
"content_type": "application/pdf"
}
]
}'
The same attachments array works on POST /visits/{id}/messages.
Each stored file comes back as:
{
"filename": "panel.pdf",
"content_type": "application/pdf",
"url": "https://files.yourapp.com/labs/panel.pdf"
}
Files you host keep the URL you sent. When a provider attaches a file in Bespoke,
url is a signed link that lasts 7 days. Call GET on the thread again
to receive a fresh link. medical_message.created includes the same list.
Posting the same external_message_id again returns the original message and does not add files.
Patient thread, no consult
Create the patient first, then post with the same fields as a consult message. No visit is created and no prescription is involved.
curl -X POST "https://a-ferreira.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": "your-pt-55",
"first_name": "Nora",
"last_name": "Ellison",
"date_of_birth": "1990-04-12",
"gender": "female",
"email": "nora@example.com",
"phone": "3055550148",
"address": {
"line1": "1200 Brickell Ave",
"city": "Miami",
"state": "FL",
"postal_code": "33131"
}
}'
curl -X POST "https://a-ferreira.bespoke-consults.designingit.co/v1/patients/your-pt-55/messages" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"message": "Can someone review my latest question?",
"external_message_id": "msg-9001",
"sender_type": "patient",
"sent_at": "2026-10-23T14:00:00Z",
"metadata": { "source": "patient_app" }
}'
Response 201. The body matches a consult message. There is no visit id on it:
{
"message_id": "…",
"external_message_id": "msg-9001",
"direction": "inbound",
"sender_type": "patient",
"sender_name": "Patient",
"body": "Can someone review my latest question?",
"attachments": [],
"sent_at": "2026-10-23T14:00:00+00:00",
"metadata": { "source": "patient_app" },
"created_at": "…"
}
List that thread:
curl "https://a-ferreira.bespoke-consults.designingit.co/v1/patients/your-pt-55/messages" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json"
{
"patient_id": "…",
"external_patient_id": "your-pt-55",
"visit_id": null,
"external_visit_id": null,
"messages": [ /* chronological, same message objects as POST */ ]
}
| Result | When |
|---|---|
201 | Message stored. Posting the same external_message_id again returns the original message and does not create a second row. That id is unique on your account, including ids already used on a visit. |
200 | GET succeeded. |
403 messaging_disabled | Medical messaging is off for this account. |
404 | No patient with that UUID or external_ref on this account. |
422 validation_failed | message is missing and there are no attachments, a file is not a JPEG, PNG, GIF, WebP, or PDF, or a field is the wrong type. message max length is 10,000 characters. |
Pass ?brand_code= on the URL when the same external_ref exists under more than one brand.
List the thread
curl "https://a-ferreira.bespoke-consults.designingit.co/v1/visits/javier-consult-1001/messages" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "Accept: application/json"
{
"visit_id": "…",
"external_visit_id": "javier-consult-1001",
"messages": [ /* chronological */ ]
}
Provider reply → your webhook
When a provider replies in Bespoke, we store an outbound message and — if
webhooks are enabled — POST:
{
"id": "delivery-uuid",
"event": "medical_message.created",
"occurred_at": "…",
"partner_id": "…",
"data": {
"message_id": "…",
"direction": "outbound",
"sender_type": "prescriber",
"sender_name": "Alan Reyes",
"body": "Apply a cool compress and message us if it worsens.",
"attachments": [],
"sent_at": "…",
"visit_id": "…",
"external_visit_id": "javier-consult-1001",
"patient_id": "…",
"external_patient_id": "javier-pt-55",
"practitioner_id": "…",
"external_practitioner_id": "dr-42"
}
}
Signatures, retries, and auth are the same as other partner callbacks — see webhook documentation.
A reply on a patient thread (no consultation) uses the same event. Match it on
patient_id or external_patient_id.
visit_id, external_visit_id, practitioner_id, and
external_practitioner_id are null. sender_name is the prescriber who replied.
{
"id": "delivery-uuid",
"event": "medical_message.created",
"occurred_at": "…",
"partner_id": "…",
"data": {
"message_id": "…",
"direction": "outbound",
"sender_type": "prescriber",
"sender_name": "Alan Reyes",
"body": "Happy to answer that here.",
"attachments": [],
"sent_at": "…",
"visit_id": null,
"external_visit_id": null,
"patient_id": "…",
"external_patient_id": "your-pt-55",
"practitioner_id": null,
"external_practitioner_id": null
}
}
Medical record
- Messages are permanent rows linked to the patient, and to a consultation when one exists.
- Visible in the prescriber Message center (the Messages inbox and the patient chart). A consult message also stays on that consultation.
- Not sent to the pharmacy network.
Integration checklist
- Ask Bespoke to enable medical messaging on your account.
- Enable webhooks and subscribe to
medical_message.created. - For a consult thread, send the visit id. For a thread with no consult, create the patient (include
state) andPOST /patients/{id}/messages. - Use
external_message_idfor safe retries from your app. It is unique per account across both thread types. A repeat does not add more files. - Send lab PDFs and photos as
attachmentsHTTPS URLs. Download provider files from theurlon each attachment. That URL refreshes when youGETthe thread. - Render provider replies from
medical_message.created. Ifvisit_idis null, attach the reply to the patient chat. PollGET /patients/{id}/messagesorGET /visits/{id}/messagesas a backup.