Booking widget
A web page that shows open times, takes the patient's details and books. Node, two files.
What it does
- The page asks for a day and calls the server, which calls
GET /available_slotsfor one provider. - The patient picks a time and fills in name, email, phone and date of birth.
- The server finds or creates the patient with
POST /patientsandreturn_existing_if_match, then books withPOST /appointments. - The page shows "waiting for the practice's system" and polls the server until the
appointment_insertionwebhook arrives withcomplete,failedortimed_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.mjsOpen 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_slotsandappointments_per_timeslotvalues the Scheduling quickstart uses, then flattens the response into a list oftime,end_time,provider_idandoperatory_id. The booking sends those four back, so the appointment lands in the operatory the slot came from. - The patient. One
POST /patientscall withreturn_existing_if_match: trueeither 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
timestampheader and the base64 of the payload with the endpoint'ssecret_key, and rejects anything that does not match. It answers at once with a200, so NexHealth does not retry a good event. - Statuses.
pendinguntil the webhook arrives, thencomplete,failedortimed_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
/hooksroute 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 /providersand pass the choice aspids[].
Updated about 1 hour ago
Did this page help you?