Booked calls Updated Nov 13, 2026

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.

Booking the call comes first: see Booked calls. Bespoke has no patient accounts: the ticket is the patient's only way in, so get it from your backend and never put it in a URL.

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 video call asks for the camera and microphone, an audio call 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

WhoWhat
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_URL is call_page_url from any ticket response. It is the same for every call on your account, so keep it in your settings.
  • Give #call a 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.

Take the consultation from the patient's login, never from the browser. A ticket lets whoever holds it join that call. If your route accepted a consultation id from the page, one patient could join another patient's call.

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.

OptionRequiredWhat it is
callPageUrlYesYour call_page_url.
getTicket()YesA 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".
themeNoYour colours.
onState(state)NoEach screen the patient sees (see Call states).
onError(code)NoSomething went wrong (see Errors).
onEnd()NoThe call is over: it ended, or the join window closed. Show the patient your next step.
  • If getTicket fails, 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 get onError('ticket_failed').
  • destroy() removes the call from your page and stops the camera and microphone.
  • A second mount on 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_url is the same for every call on your account. The ticket never goes in this URL.
StatusCodeWhen
403video_visits_disabledVideo visits are not enabled for your account.
403origin_not_allowedThis website or your server's source IP is not registered.
404not_foundNo consultation with that id on your account.
404no_appointmentThe consultation has no booked call.
409join_window_closedToo 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.
409not_a_video_consultationThe consultation's category does not offer calls.
422validation_failedThe consultation is closed (fields.consultation).

Errors

The codes onError receives. On "Try again" the script gets a new ticket by itself.

CodeMeaningWhat to do
ticket_failedgetTicket failed, returned no ticket, or took too long. Script only.Check your backend route. The patient can press "Try again".
page_not_loadedThe 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_ticketThe ticket was used, expired, belongs to another account, or the join window has closed.Nothing: the patient can press "Try again".
networkThe call page could not reach Bespoke.Nothing: the patient can press "Try again".
rate_limitedToo many tickets sent from this network in a minute.Wait a minute.
unavailableThe 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.

StateThe patient sees
loadingGetting the call ready (waiting for a ticket).
setup, asking, previewDevice check: the browser's permission prompt, then a microphone level meter and camera preview, with a choice of device.
permission_denied, device_missing, device_busyA device problem, with instructions and a "Try again" button.
unsupportedThis browser cannot make calls; use a recent Chrome, Safari, Edge, or Firefox.
joiningJoining the call.
waitingThe waiting room, until the prescriber lets the patient in.
in_callThe call.
reconnectingThe connection dropped and is being restored, over the call.
network_lost, leftThe connection was lost, or the patient left. A "Rejoin" button works while the call is open.
endedThe call has ended: the prescriber finished it, or it was cancelled.
expiredThe call's time is over: its join window closed, or the open call reached its end.
invalid_ticket, errorThe 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.

FieldValueUsed for
accent#rgb or #rrggbbButtons and highlights. The button text turns dark or light on its own to stay readable.
background#rgb or #rrggbbPage background.
surface#rgb or #rrggbbCards and panels.
text#rgb or #rrggbbText.
radiusNumber, 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 src to your call_page_url, so you do not miss bespoke-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.

DirectiontypeData
Call page → youbespoke-call:readyNone. 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 pagebespoke-call:startticket (required), theme (optional).
You → call pagebespoke-call:no-tickettheme (optional). You could not get a ticket: the patient sees "We could not open the call" with "Try again", instead of waiting.
Call page → youbespoke-call:statestate, see Call states.
Call page → youbespoke-call:errorcode: 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

  1. Ask Bespoke to enable video visits and to register every website that will show the call, and your server's source IPs.
  2. Show the patient a "Join" button from 10 minutes before the start. On 409 join_window_closed, show when the call opens.
  3. Get tickets from your backend only; your API key never goes to the browser.
  4. Take the consultation from the patient's login, never from the browser.
  5. Give the call's element a height, at least 600px.
  6. On onEnd, show the patient what happens next.
  7. Test on your site over https:// with a phone as well as a computer.