Joining a call
When a booked call is about to start, the patient joins it 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. The call page does the rest: it checks the patient's microphone (and camera, for a video call), waits until the prescriber lets the patient in, and runs the call.
Overview
- The address bar stays on your website. The call page shows no Bespoke branding and sets no cookies. You can match it to your colours.
- A
videocall asks for the camera and microphone, anaudiocall for the microphone only. - The patient waits in a waiting room until the prescriber lets them in.
- A call can be joined from 10 minutes before it starts until 30 minutes after its booked end (the join window). A call the patient or the prescriber entered in time is not cut off then: it stays open for 60 more minutes, so a patient waiting for a prescriber who runs late still gets the call. After that the call closes for everyone.
- If the patient leaves or loses the connection, they can rejoin from the same page while the call is open, without a new ticket.
- What the call page reports to your page carries no patient data.
Before you start
| Who | What |
|---|---|
| Bespoke admin | Turns on Video visits for your account (see Booked calls). |
| Bespoke admin | Registers your websites and your server's source IPs, the same allow-list as for the API. Only these websites can show the call page, and only over https://. On any other website the browser refuses to show it. |
| You | If your site sends a Content Security Policy, allow https://a-nirwan.bespoke-consults.designingit.co in script-src and frame-src. If it sends a Permissions-Policy, allow camera and microphone for that address too. |
Quick start
This is the whole of your page. /your-backend/call-ticket is your own route.
<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_CALL_PAGE_URLiscall_page_urlfrom any ticket response. It is the same for every call on your account, so keep it in your settings.- Give
#calla height in your styles. The call fills it, and needs at least 600px; on a phone, use most of the screen. - Show the button only inside the join window: from 10 minutes before the call's
starts_at.
Your backend route
The route asks Bespoke for a ticket with your API key, which stays on your server, and returns it to your page. Your page calls it each time the call page needs a ticket.
PHP
<?php
// POST /your-backend/call-ticket
// Your own login check decides which consultation this patient may join.
$consult = your_logged_in_patients_consult();
$request = curl_init('https://a-nirwan.bespoke-consults.designingit.co/v1/visits/' . rawurlencode($consult) . '/video/patient-ticket');
curl_setopt_array($request, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'X-Api-Key: ' . getenv('BESPOKE_KEY'),
'X-Api-Secret: ' . getenv('BESPOKE_SECRET'),
'X-Source-Domain: your-site.example',
],
]);
$body = json_decode((string) curl_exec($request), true) ?: [];
$status = curl_getinfo($request, CURLINFO_HTTP_CODE);
header('Content-Type: application/json');
header('Cache-Control: no-store');
if ($status !== 201) {
http_response_code($status ?: 502);
echo json_encode([
'error' => $body['error']['code'] ?? 'api_error',
'join_window' => $body['join_window'] ?? null,
]);
exit;
}
echo json_encode(['ticket' => $body['ticket']]);
Node
With Express and Node 18 or later. requireLogin is your own login check.
app.post('/your-backend/call-ticket', requireLogin, async (req, res) => {
// Your own login decides which consultation this patient may join.
const consult = req.user.consultId;
const api = await fetch(`https://a-nirwan.bespoke-consults.designingit.co/v1/visits/${encodeURIComponent(consult)}/video/patient-ticket`, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-Api-Key': process.env.BESPOKE_KEY,
'X-Api-Secret': process.env.BESPOKE_SECRET,
'X-Source-Domain': 'your-site.example',
},
signal: AbortSignal.timeout(15000),
});
const body = await api.json();
res.set('Cache-Control', 'no-store');
if (api.status !== 201) {
return res.status(api.status).json({
error: body.error?.code ?? 'api_error',
join_window: body.join_window ?? null,
});
}
res.json({ ticket: body.ticket });
});
X-Source-Domain is one of your registered websites, without
https://. A 403 origin_not_allowed means this website or your
server's source IP is not registered yet.
The script
https://a-nirwan.bespoke-consults.designingit.co/video/embed.js gives your page BespokeCall.mount(element, options),
which adds the call inside element and returns { destroy() }.
Load it from this address rather than keeping a copy: fixes then reach you by themselves.
| Option | Required | What it is |
|---|---|---|
callPageUrl | Yes | Your call_page_url. |
getTicket() | Yes | A function returning a promise of the ticket, as a string, from your backend. It runs each time the call page needs a ticket: when it loads, and when the patient presses "Try again". |
theme | No | Your colours. |
onState(state) | No | Each screen the patient sees (see Call states). |
onError(code) | No | Something went wrong (see Errors). |
onEnd() | No | The call is over: it ended, or the join window closed. Show the patient your next step. |
- If
getTicketfails, returns anything but a ticket, or takes more than 30 seconds, the patient sees "We could not open the call" with a "Try again" button, and you getonError('ticket_failed'). destroy()removes the call from your page and stops the camera and microphone.- A second
mounton an element that already holds a call returns that same call, so a double click does not open two.
The ticket endpoint
POST https://a-nirwan.bespoke-consults.designingit.co/v1/visits/{id}/video/patient-ticket, from your backend, with
the usual API headers. {id} is the consultation id or your
external_ref. No body.
curl -X POST "https://a-nirwan.bespoke-consults.designingit.co/v1/visits/your-consult-2001/video/patient-ticket" \
-H "X-Api-Key: $BESPOKE_KEY" \
-H "X-Api-Secret: $BESPOKE_SECRET" \
-H "X-Source-Domain: your-site.example"
201:
{
"ticket": "vt_…",
"expires_at": "2026-11-05T14:55:00Z",
"visit_id": "…",
"appointment_id": "…",
"modality": "video",
"join_window": { "opens_at": "2026-11-05T14:50:00Z", "closes_at": "2026-11-05T15:40:00Z" },
"call_page_url": "https://…/video/call/…"
}
- A ticket works once and for 5 minutes. Get a new one each time the call page needs one, not when the call is booked.
- Asking again gives a new ticket; earlier unused ones still work until they expire.
call_page_urlis the same for every call on your account. The ticket never goes in this URL.
| Status | Code | When |
|---|---|---|
| 403 | video_visits_disabled | Video visits are not enabled for your account. |
| 403 | origin_not_allowed | This website or your server's source IP is not registered. |
| 404 | not_found | No consultation with that id on your account. |
| 404 | no_appointment | The consultation has no booked call. |
| 409 | join_window_closed | Too early or too late. The response also has join_window with opens_at and closes_at, so you can tell the patient when to come back. |
| 409 | not_a_video_consultation | The consultation's category does not offer calls. |
| 422 | validation_failed | The consultation is closed (fields.consultation). |
Errors
The codes onError receives. On "Try again" the script gets a new ticket by itself.
| Code | Meaning | What to do |
|---|---|---|
ticket_failed | getTicket failed, returned no ticket, or took too long. Script only. | Check your backend route. The patient can press "Try again". |
page_not_loaded | The call page did not answer within 20 seconds: this website is not registered, video visits are off for your account, or the network is down. Script only. | Check the browser's console. If it repeats on a registered website, contact Bespoke. |
invalid_ticket | The ticket was used, expired, belongs to another account, or the join window has closed. | Nothing: the patient can press "Try again". |
network | The call page could not reach Bespoke. | Nothing: the patient can press "Try again". |
rate_limited | Too many tickets sent from this network in a minute. | Wait a minute. |
unavailable | The call could not be opened, before the patient got in. | Let the patient try again. If it repeats, contact Bespoke. |
Call states
Each state names the screen the patient sees. Use them to update your own page, or just use onEnd.
| State | The patient sees |
|---|---|
loading | Getting the call ready (waiting for a ticket). |
setup, asking, preview | Device check: the browser's permission prompt, then a microphone level meter and camera preview, with a choice of device. |
permission_denied, device_missing, device_busy | A device problem, with instructions and a "Try again" button. |
unsupported | This browser cannot make calls; use a recent Chrome, Safari, Edge, or Firefox. |
joining | Joining the call. |
waiting | The waiting room, until the prescriber lets the patient in. |
in_call | The call. |
reconnecting | The connection dropped and is being restored, over the call. |
network_lost, left | The connection was lost, or the patient left. A "Rejoin" button works while the call is open. |
ended | The call has ended: the prescriber finished it, or it was cancelled. |
expired | The call's time is over: its join window closed, or the open call reached its end. |
invalid_ticket, error | The ticket did not work, or the call could not be opened, with a "Try again" button. |
Your colours
Optional theme, for example { accent: '#0f766e', radius: 12 }. A value that is missing or not valid keeps the default.
| Field | Value | Used for |
|---|---|---|
accent | #rgb or #rrggbb | Buttons and highlights. The button text turns dark or light on its own to stay readable. |
background | #rgb or #rrggbb | Page background. |
surface | #rgb or #rrggbb | Cards and panels. |
text | #rgb or #rrggbb | Text. |
radius | Number, 0 to 32 (pixels) | Corner rounding. |
Without the script
The script is the simple way. If you would rather write this part yourself, it does only this: an iframe and postMessage.
<iframe id="bespoke-call" title="Your call" allow="camera; microphone"></iframe>
allow="camera; microphone"is required. Without it the browser blocks the devices and the patient sees "Allow camera and microphone".- Start listening for messages before you set the iframe's
srcto yourcall_page_url, so you do not missbespoke-call:ready. - Opened directly, outside an iframe, the page only says to open the call from your website.
Accept messages only from the iframe (event.source) and its origin (the origin
of call_page_url), and send only to that origin. The call page accepts your
messages only from a registered website, and after the first one talks only to the
origin that sent it.
| Direction | type | Data |
|---|---|---|
| Call page → you | bespoke-call:ready | None. The page needs a ticket: send bespoke-call:start with a new one. Sent when the page loads, and again when the patient presses "Try again" after a ticket or connection problem. |
| You → call page | bespoke-call:start | ticket (required), theme (optional). |
| You → call page | bespoke-call:no-ticket | theme (optional). You could not get a ticket: the patient sees "We could not open the call" with "Try again", instead of waiting. |
| Call page → you | bespoke-call:state | state, see Call states. |
| Call page → you | bespoke-call:error | code: the codes in Errors that are not marked "Script only". |
<iframe id="bespoke-call" title="Your call" allow="camera; microphone"></iframe>
<script>
const callPageUrl = 'YOUR_CALL_PAGE_URL';
const callOrigin = new URL(callPageUrl).origin;
const frame = document.getElementById('bespoke-call');
window.addEventListener('message', async (event) => {
if (event.source !== frame.contentWindow || event.origin !== callOrigin) return;
const message = event.data || {};
if (message.type === 'bespoke-call:ready') {
const response = await fetch('/your-backend/call-ticket', { method: 'POST' });
frame.contentWindow.postMessage(response.ok
? { type: 'bespoke-call:start', ticket: (await response.json()).ticket }
: { type: 'bespoke-call:no-ticket' }, callOrigin);
}
if (message.type === 'bespoke-call:state' && message.state === 'ended') {
// Show the patient your next step.
}
});
// Listen first, then load the page.
frame.src = callPageUrl;
</script>
Integration checklist
- Ask Bespoke to enable video visits and to register every website that will show the call, and your server's source IPs.
- Show the patient a "Join" button from 10 minutes before the start. On
409 join_window_closed, show when the call opens. - Get tickets from your backend only; your API key never goes to the browser.
- Take the consultation from the patient's login, never from the browser.
- Give the call's element a height, at least 600px.
- On
onEnd, show the patient what happens next. - Test on your site over
https://with a phone as well as a computer.