Booking widget

A web page that shows open times, takes the patient's details and books. Node, two files.

What it does

  1. The page asks for a day and calls the server, which calls GET /available_slots for one provider.
  2. The patient picks a time and fills in name, email, phone and date of birth.
  3. The server finds or creates the patient with POST /patients and return_existing_if_match, then books with POST /appointments.
  4. The page shows "waiting for the practice's system" and polls the server until the appointment_insertion webhook arrives with complete, failed or timed_out.

Run it

export NEX_API_KEY="your-sandbox-api-key"
export NEX_SUBDOMAIN="your-subdomain"
export NEX_LOCATION_ID="your-location-id"
export NEX_PROVIDER_ID="a-provider-id"        # from GET /providers
export NEX_APPT_TYPE_ID="an-appointment-type-id"  # optional, from GET /appointment_types
export NEX_WEBHOOK_SECRET="the-endpoint-secret_key"  # optional, from POST /webhook_endpoints

node server.mjs

Open http://localhost:3000. Node 18 or later.

To receive the confirmation, register https://your-host/hooks as a webhook endpoint and subscribe to appointment_insertion. See Setting up webhooks. Without a public URL the booking still works, and the page keeps waiting.

The code

// Booking widget: a small web server that shows open times and books one.
// Run: node server.mjs   (Node 18 or later, no packages to install)
// Then open http://localhost:3000
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { createHmac } from "node:crypto";

const BASE = process.env.NEX_BASE || "https://nexhealth.info";
const { NEX_API_KEY, NEX_SUBDOMAIN, NEX_LOCATION_ID, NEX_PROVIDER_ID, NEX_APPT_TYPE_ID, NEX_WEBHOOK_SECRET } = process.env;
const SLOT_MINUTES = Number(process.env.SLOT_MINUTES || 30);
const PORT = Number(process.env.PORT || 3000);

// --- NexHealth API -------------------------------------------------------

let token = { value: null, expiresAt: 0 };

// Trade the API key for a bearer token. Tokens last 1 hour in production, so refresh after 50 minutes.
async function getToken() {
  if (token.value && Date.now() < token.expiresAt) return token.value;
  const res = await fetch(BASE + "/authenticates", {
    method: "POST",
    headers: { "Nex-Api-Version": "v3.0.0", Authorization: NEX_API_KEY },
  });
  const json = await res.json();
  if (!res.ok || json.code === false) throw new Error("Token exchange failed: " + JSON.stringify(json.error));
  token = { value: json.data.token, expiresAt: Date.now() + 50 * 60 * 1000 };
  return token.value;
}

async function api(method, path, { query = {}, body, accept } = {}) {
  const url = new URL(BASE + path);
  for (const [key, value] of Object.entries(query)) {
    if (Array.isArray(value)) value.forEach((v) => url.searchParams.append(key, v));
    else url.searchParams.set(key, value);
  }
  const res = await fetch(url, {
    method,
    headers: {
      "Nex-Api-Version": "v3.0.0",
      Authorization: "Bearer " + (await getToken()),
      ...(accept ? { accept } : {}),
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  const json = text ? JSON.parse(text) : {};
  if (!res.ok || json.code === false) {
    const err = new Error(Array.isArray(json.error) ? json.error.join("; ") : method + " " + path + " failed with " + res.status);
    err.status = res.status;
    throw err;
  }
  return json.data;
}

const scope = { subdomain: NEX_SUBDOMAIN, location_id: NEX_LOCATION_ID };

// Open times for one day, flattened to a list the page can show.
async function findSlots(date) {
  const data = await api("GET", "/available_slots", {
    accept: "application/vnd.Nexhealth+json;version=2",
    query: {
      subdomain: NEX_SUBDOMAIN,
      start_date: date,
      days: 1,
      "lids[]": [NEX_LOCATION_ID],
      "pids[]": [NEX_PROVIDER_ID],
      slot_length: SLOT_MINUTES,
      overlapping_operatory_slots: false,
      appointments_per_timeslot: 1,
    },
  });
  return data.flatMap((group) =>
    group.slots.map((slot) => ({
      time: slot.time,
      end_time: slot.end_time,
      provider_id: slot.provider_id ?? group.pid,
      operatory_id: slot.operatory_id ?? group.operatory_id,
    }))
  );
}

// Find the patient by what they typed, or create them. return_existing_if_match avoids duplicates.
async function findOrCreatePatient(form) {
  const data = await api("POST", "/patients", {
    query: scope,
    body: {
      provider: { provider_id: Number(NEX_PROVIDER_ID) },
      patient: {
        first_name: form.first_name,
        last_name: form.last_name,
        email: form.email,
        bio: { date_of_birth: form.date_of_birth, phone_number: form.phone_number },
      },
      return_existing_if_match: true,
    },
  });
  return data.user;
}

async function bookAppointment(patient, slot, note) {
  const appt = {
    patient_id: patient.id,
    provider_id: slot.provider_id,
    operatory_id: slot.operatory_id,
    start_time: slot.time,
    end_time: slot.end_time,
    note: (note || "Booked online").slice(0, 128),
  };
  if (NEX_APPT_TYPE_ID) appt.appointment_type_id = Number(NEX_APPT_TYPE_ID);
  const data = await api("POST", "/appointments", { query: scope, body: { appt } });
  return data.appt;
}

// --- Write confirmation ---------------------------------------------------
// A 201 means NexHealth accepted the booking. The appointment_insertion webhook says
// whether it landed in the health record system. Statuses: pending, complete, failed, timed_out.

const status = new Map();

function verifySignature(rawBody, timestamp, signature) {
  if (!NEX_WEBHOOK_SECRET) return true; // set NEX_WEBHOOK_SECRET to the endpoint's secret_key to verify
  const compact = JSON.stringify(JSON.parse(rawBody));
  const signed = timestamp + "." + Buffer.from(compact).toString("base64");
  const expected = createHmac("sha256", NEX_WEBHOOK_SECRET).update(signed).digest("hex");
  return expected === signature;
}

function handleWebhook(rawBody, headers) {
  if (!verifySignature(rawBody, headers.timestamp, headers.signature)) return false;
  const event = JSON.parse(rawBody);
  const match = /^appointment_insertion\.(complete|failed|timed_out)$/.exec(event.event_name || "");
  if (match) status.set(event.data.appointment.id, match[1]);
  return true;
}

// --- HTTP ------------------------------------------------------------------

const readBody = (req) =>
  new Promise((resolve) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => resolve(data));
  });

const send = (res, code, payload, type = "application/json") => {
  res.writeHead(code, { "Content-Type": type });
  res.end(type === "application/json" ? JSON.stringify(payload) : payload);
};

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  try {
    if (req.method === "GET" && url.pathname === "/") {
      return send(res, 200, await readFile(new URL("./index.html", import.meta.url), "utf8"), "text/html");
    }
    if (req.method === "GET" && url.pathname === "/api/slots") {
      return send(res, 200, { slots: await findSlots(url.searchParams.get("date")) });
    }
    if (req.method === "POST" && url.pathname === "/api/book") {
      const form = JSON.parse(await readBody(req));
      const patient = await findOrCreatePatient(form);
      const appt = await bookAppointment(patient, form.slot, form.note);
      status.set(appt.id, "pending");
      return send(res, 201, { appointment_id: appt.id, start_time: appt.start_time });
    }
    if (req.method === "GET" && url.pathname.startsWith("/api/status/")) {
      const id = Number(url.pathname.split("/").pop());
      return send(res, 200, { status: status.get(id) || "unknown" });
    }
    if (req.method === "POST" && url.pathname === "/hooks") {
      // Answer right away. NexHealth retries when it does not get a 2xx.
      const ok = handleWebhook(await readBody(req), req.headers);
      return send(res, ok ? 200 : 401, { received: ok });
    }
    send(res, 404, { error: "Not found" });
  } catch (err) {
    send(res, err.status === 400 ? 400 : 500, { error: err.message });
  }
});

server.listen(PORT, () => console.log("Booking widget on http://localhost:" + PORT));
<!doctype html>
<meta charset="utf-8">
<title>Book a visit</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; color: #111; }
  h1 { font-size: 24px; }
  label { display: block; margin: 12px 0 4px; font-weight: 600; }
  input, button { font: inherit; padding: 8px 10px; border: 1px solid #bbb; border-radius: 6px; width: 100%; box-sizing: border-box; }
  button { background: #111; color: #fff; border-color: #111; cursor: pointer; margin-top: 12px; }
  .slot { width: auto; margin: 4px 4px 0 0; background: #fff; color: #111; }
  .slot.picked { background: #111; color: #fff; }
  #result { margin-top: 16px; padding: 12px; border-radius: 6px; background: #f3f3f3; }
  .hidden { display: none; }
</style>

<h1>Book a visit</h1>

<label for="date">Day</label>
<input id="date" type="date">
<button id="find">Find times</button>
<div id="slots"></div>

<form id="book" class="hidden">
  <label>First name <input name="first_name" required></label>
  <label>Last name <input name="last_name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Phone <input name="phone_number" required></label>
  <label>Date of birth <input name="date_of_birth" type="date" required></label>
  <label>Reason for the visit <input name="note" maxlength="128"></label>
  <button type="submit">Book</button>
</form>

<div id="result" class="hidden"></div>

<script>
  const $ = (id) => document.getElementById(id);
  let picked = null;

  const tomorrow = new Date(Date.now() + 86400000);
  $("date").value = tomorrow.toISOString().slice(0, 10);

  $("find").onclick = async () => {
    $("slots").textContent = "Looking...";
    const res = await fetch("/api/slots?date=" + $("date").value);
    const { slots, error } = await res.json();
    if (error) return ($("slots").textContent = error);
    if (!slots.length) return ($("slots").textContent = "No open times that day. Try another.");
    $("slots").innerHTML = "";
    for (const slot of slots) {
      const b = document.createElement("button");
      b.className = "slot";
      b.textContent = new Date(slot.time).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
      b.onclick = () => {
        picked = slot;
        document.querySelectorAll(".slot").forEach((x) => x.classList.remove("picked"));
        b.classList.add("picked");
        $("book").classList.remove("hidden");
      };
      $("slots").appendChild(b);
    }
  };

  $("book").onsubmit = async (e) => {
    e.preventDefault();
    const form = Object.fromEntries(new FormData(e.target));
    const res = await fetch("/api/book", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ...form, slot: picked }),
    });
    const data = await res.json();
    $("result").classList.remove("hidden");
    if (data.error) return ($("result").textContent = data.error);
    $("result").textContent = "Booked for " + new Date(data.start_time).toLocaleString() + ". Waiting for the practice's system to confirm...";
    const poll = setInterval(async () => {
      const { status } = await (await fetch("/api/status/" + data.appointment_id)).json();
      if (status === "complete") { $("result").textContent = "Confirmed. See you then."; clearInterval(poll); }
      if (status === "failed" || status === "timed_out") { $("result").textContent = "The practice's system did not accept the time. Please pick another."; clearInterval(poll); }
    }, 5000);
  };
</script>

How it works

  • Slots. The server asks for one day of slots with the same slot_length, overlapping_operatory_slots and appointments_per_timeslot values the Scheduling quickstart uses, then flattens the response into a list of time, end_time, provider_id and operatory_id. The booking sends those four back, so the appointment lands in the operatory the slot came from.
  • The patient. One POST /patients call with return_existing_if_match: true either creates the patient or returns the existing match, so the page never creates a duplicate. See Patients configuration guide.
  • The webhook. NexHealth signs each event. The server rebuilds the signature from the timestamp header and the base64 of the payload with the endpoint's secret_key, and rejects anything that does not match. It answers at once with a 200, so NexHealth does not retry a good event.
  • Statuses. pending until the webhook arrives, then complete, failed or timed_out. A failed write means the health record system rejected the time. See Troubleshooting appointment writes.

Change it for production

  • Keep the API key on the server. The page never sees it.
  • Serve the page and the /hooks route over HTTPS. Webhook endpoints have to be HTTPS.
  • Store the status map in a database. The in-memory map here is lost on restart.
  • Let the patient pick a provider: list them with GET /providers and pass the choice as pids[].

Did this page help you?