Guides
Guides
Complete examples of things businesses build.
Each guide is a whole program you can run and change. They share a few habits worth keeping: the
key lives on a server or a device the business controls and is read from
LINEMEUP_API_KEY; each one makes its own key, with only what it needs; and every
call waits out a 429 rather than hammering.
A booking widget on your own website#
A "Join the line" form on the business's own site: a visitor picks a line, gives a name and a phone number, and gets a token and their place, which updates while they wait. It adds a walk-in — the person joins now. Bookings for a later slot stay on the business's Line Me Up page.
The key: read and visits:write, limited to the lines offered online.
The browser never sees the key. It talks to your server; your server talks to Line Me Up, caches the line list for ten seconds, and passes back only the token, the status and the place — not the name or number of anybody else.
// widget-server.mjs — runs on your website's server. The key stays here.
// npm install express
import express from "express";
const API = "https://business.linemeup.in/api/v1";
async function lineMeUp(method, path, body, extraHeaders = {}) {
for (;;) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.LINEMEUP_API_KEY}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? "5");
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
const answer = await res.json();
if (!res.ok) {
const error = new Error(answer.error?.message ?? `Line Me Up answered ${res.status}`);
error.status = res.status;
error.code = answer.error?.code;
error.details = answer.error?.details;
throw error;
}
return answer;
}
}
const app = express();
app.use(express.json());
// The lines a visitor may join from the website, cached for ten seconds so a
// busy page costs one request every ten seconds, not one per visitor.
let cached = { at: 0, lines: [] };
app.get("/queue/lines", async (_req, res) => {
if (Date.now() - cached.at > 10_000) {
const { data } = await lineMeUp("GET", "/lines");
cached = {
at: Date.now(),
lines: data
.filter((line) => !line.paused && line.type !== "appointment")
.map(({ id, name, waiting, avgServiceMinutes }) => ({
id,
name,
waiting,
avgServiceMinutes,
})),
};
}
res.json(cached.lines);
});
// Join one. formId is made by the browser when the form opens, so a double
// click or a retry after a timeout adds one visit, not two.
app.post("/queue/join", async (req, res) => {
const { lineId, name, phone, consent, formId } = req.body;
try {
const { data } = await lineMeUp(
"POST",
"/visits",
{ lineId, customer: { name, phone }, consent: consent === true },
{ "Idempotency-Key": `website-${formId}` },
);
const [visit] = data;
res.json({ visitId: visit.id, token: visit.token, position: visit.position });
} catch (error) {
// A 422 names the field that is wrong — pass it back to the form.
if (error.status === 422) return res.status(422).json({ fields: error.details });
res.status(502).json({ message: "We could not add you just now. Please try again." });
}
});
// The visitor's page asks every fifteen seconds where they are.
app.get("/queue/visits/:visitId", async (req, res) => {
try {
const visit = await lineMeUp("GET", `/visits/${encodeURIComponent(req.params.visitId)}`);
res.json({ token: visit.token, status: visit.status, position: visit.position });
} catch (error) {
res.status(error.status === 404 ? 404 : 502).end();
}
});
app.listen(3000); <!-- On your page. It talks to your server, never to Line Me Up. -->
<form id="join">
<select name="lineId" required></select>
<input name="name" autocomplete="name" required placeholder="Your name" />
<input name="phone" autocomplete="tel" required placeholder="Phone, with country code" />
<label><input type="checkbox" name="consent" /> Send me updates about my place</label>
<button>Join the line</button>
</form>
<p id="place" hidden></p>
<script type="module">
const form = document.getElementById("join");
const place = document.getElementById("place");
const formId = crypto.randomUUID();
const lines = await (await fetch("/queue/lines")).json();
form.lineId.replaceChildren(
...lines.map((line) => new Option(`${line.name} — ${line.waiting} waiting`, line.id)),
);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const res = await fetch("/queue/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
formId,
lineId: form.lineId.value,
name: form.name.value,
phone: form.phone.value,
consent: form.consent.checked,
}),
});
if (!res.ok) return alert("Please check your details and try again.");
const { visitId, token } = await res.json();
form.hidden = true;
place.hidden = false;
const show = async () => {
const now = await (await fetch(`/queue/visits/${visitId}`)).json();
place.textContent =
now.status === "queued"
? `Your number is ${token}. You are number ${now.position} in line.`
: now.status === "called"
? `${token} — it is your turn. Please come to the desk.`
: `${token}: ${now.status.replace("_", " ")}`;
};
await show();
setInterval(show, 15_000);
});
</script> - The form makes one id when it opens and sends it as the Idempotency-Key, so a double click adds one visit.
-
A
422names the field that is wrong — usually the phone — so show it under that field. - Fifteen seconds between checks is plenty. Five hundred people waiting on a busy day, all polling, is about 2,000 requests a minute to your server; cache each visit's answer for a few seconds there, so Line Me Up sees far fewer.
Your own lobby screen#
A screen in the waiting room, in the business's own design, showing the tokens being called and the room to go to. Line Me Up's own TV display does this already; build your own when it has to sit inside something else.
The key: read only, limited to the lines of that waiting room.
Show a token and a room, never a name. A lobby is public, and the token exists so nobody's name has to be on a wall.
// lobby.mjs — a small server your lobby screen reads. Node 18 or later.
import { createServer } from "node:http";
const API = "https://business.linemeup.in/api/v1";
async function lineMeUp(method, path, body, extraHeaders = {}) {
for (;;) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.LINEMEUP_API_KEY}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? "5");
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
const answer = await res.json();
if (!res.ok) {
const error = new Error(answer.error?.message ?? `Line Me Up answered ${res.status}`);
error.status = res.status;
error.code = answer.error?.code;
error.details = answer.error?.details;
throw error;
}
return answer;
}
}
let board = { calling: [], waiting: [], at: null };
async function refresh() {
const { today } = await lineMeUp("GET", "/business");
const [{ data: desks }, { data: lines }, { data: called }] = await Promise.all([
lineMeUp("GET", "/desks"),
lineMeUp("GET", "/lines"),
lineMeUp("GET", `/visits?status=called&from=${today}&to=${today}&limit=20`),
]);
const deskLabel = new Map(desks.map((desk) => [desk.id, desk.publicLabel ?? desk.name]));
board = {
// A token and a room — never a name. The screen is in a public room.
calling: called.map((visit) => ({
token: visit.token,
desk: deskLabel.get(visit.deskId) ?? "",
})),
waiting: lines.map((line) => ({ line: line.name, waiting: line.waiting, paused: line.paused })),
at: new Date().toISOString(),
};
}
// Four reads every five seconds is 48 a minute — far inside 600 for one key.
setInterval(() => refresh().catch((error) => console.error(error.message)), 5_000);
await refresh();
createServer((req, res) => {
if (req.url !== "/board.json") return res.writeHead(404).end();
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
res.end(JSON.stringify(board));
}).listen(8080); <!-- The lobby screen: a full-screen page reading your server's /board.json. -->
<ul id="calling" aria-live="polite"></ul>
<script type="module">
const list = document.getElementById("calling");
async function draw() {
const board = await (await fetch("/board.json")).json();
list.replaceChildren(
...board.calling.map(({ token, desk }) => {
const item = document.createElement("li");
item.textContent = `${token} → ${desk}`;
return item;
}),
);
}
await draw();
setInterval(draw, 3000);
</script>
For a call to appear the moment it happens rather than within five seconds, subscribe an
endpoint to visit.status_changed and push each called to the screen, keeping
the poll as a fallback.
Sync visits to a CRM#
Keep a CRM or a patient record in step with every visit: who came, when they were seen, how it ended. Webhooks bring each change within seconds; a nightly read catches anything a webhook missed.
The key: read only, for the nightly read. The endpoint: visit.created, visit.status_changed and
visit.updated, all lines.
// crm-sync.mjs — receives Line Me Up's webhooks and keeps your CRM in step.
// npm install express
import express from "express";
import { verifyLineMeUp } from "./verify-line-me-up.mjs"; // from the Webhooks page
// `crm` is your CRM's own client, and `store` your database: two functions each.
import { crm, store } from "./your-systems.mjs";
const app = express();
app.post("/webhooks/line-me-up", express.raw({ type: "application/json" }), async (req, res) => {
const ok = verifyLineMeUp(
req.body,
req.get("LineMeUp-Signature"),
process.env.LINEMEUP_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("Bad signature");
res.sendStatus(200); // answer first; Line Me Up waits ten seconds at most
const event = JSON.parse(req.body.toString("utf8"));
if (event.test) return; // Send test event — a sample, not a real visit
if (!event.type.startsWith("visit.")) return;
// A retry or a redelivery has the same event id: do the work once.
if (!(await store.firstTimeSeen(event.id))) return;
// Events can arrive out of order. Keep a visit only if it is newer than ours.
const { visit } = event.data;
const ours = await store.visitUpdatedAt(visit.id);
if (ours !== null && ours >= visit.updatedAt) return;
await crm.upsertVisit({
externalId: visit.id,
token: visit.token,
status: visit.status,
name: visit.customer.name,
phone: visit.customer.phone,
services: visit.services.map((service) => service.name).join(", "),
arrivedAt: visit.createdAt,
servedAt: visit.startedAt,
finishedAt: visit.endedAt,
});
await store.rememberVisit(visit.id, visit.updatedAt);
});
app.listen(3000); -
It answers
200before talking to the CRM, so a slow CRM never makes Line Me Up wait the ten seconds and retry. - It skips an event id it has seen: retries and redeliveries repeat the id.
-
It keeps a visit only if it is newer than the copy it has, by
updatedAt: events can arrive out of order. -
crmandstoreare yours: an upsert into the CRM, and a table of event ids and each visit's lastupdatedAt.
// reconcile.mjs — run every night. Picks up anything a webhook missed.
import { crm, store } from "./your-systems.mjs";
const API = "https://business.linemeup.in/api/v1";
async function lineMeUp(method, path, body, extraHeaders = {}) {
for (;;) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.LINEMEUP_API_KEY}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? "5");
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
const answer = await res.json();
if (!res.ok) {
const error = new Error(answer.error?.message ?? `Line Me Up answered ${res.status}`);
error.status = res.status;
error.code = answer.error?.code;
error.details = answer.error?.details;
throw error;
}
return answer;
}
}
const since = (await store.lastReconciledAt()) ?? new Date(Date.now() - 86_400_000).toISOString();
const startedAt = new Date().toISOString();
let cursor = null;
do {
const query = new URLSearchParams({ updatedSince: since, limit: "100" });
if (cursor) query.set("cursor", cursor);
const page = await lineMeUp("GET", `/visits?${query}`);
for (const visit of page.data) {
const ours = await store.visitUpdatedAt(visit.id);
if (ours !== null && ours >= visit.updatedAt) continue;
await crm.upsertVisit({
externalId: visit.id,
token: visit.token,
status: visit.status,
name: visit.customer.name,
phone: visit.customer.phone,
});
await store.rememberVisit(visit.id, visit.updatedAt);
}
cursor = page.nextCursor;
} while (cursor);
await store.setLastReconciledAt(startedAt); The reconcile remembers when it started, not when it finished, so nothing that changed during the run is skipped next time.
Call next from a button at a desk#
A physical button at a doctor's door or a counter: press it and the next person is called to that desk, as if the desk's own Call next were pressed. This one runs on a Raspberry Pi with a push button between GPIO 17 and ground.
The key: desks:operate only, limited to that one desk. A device can be lost or opened; this key
can call people at one desk and do nothing else.
# call_next_button.py — a push button on a Raspberry Pi calls the next person.
# pip install gpiozero requests
import os
import threading
import time
import requests
from gpiozero import Button
from signal import pause
API = "https://business.linemeup.in/api/v1"
DESK_ID = os.environ["LINEMEUP_DESK_ID"]
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["LINEMEUP_API_KEY"]
busy = threading.Lock()
def show(text):
print(text) # or write it to a small display beside the button
def call_next():
# One press at a time: a second press while the first is in flight is ignored.
if not busy.acquire(blocking=False):
return
try:
res = session.post(API + "/desks/" + DESK_ID + "/call-next", json={}, timeout=10)
if res.status_code == 429:
show("Too fast — wait " + res.headers.get("Retry-After", "a few") + " seconds")
elif res.status_code == 409:
show(res.json()["error"]["message"]) # e.g. the desk is not on shift
elif res.ok:
visit = res.json()["visit"]
show("Nobody waiting" if visit is None else "Calling " + visit["token"])
else:
show("Line Me Up answered " + str(res.status_code))
except requests.Timeout:
# Do not press again for them: the call may have gone through.
show("No answer — check the board before pressing again")
finally:
time.sleep(1)
busy.release()
button = Button(17, bounce_time=0.2) # GPIO 17 to ground
button.when_pressed = call_next
show("Ready")
pause() -
The desk's id is in
GET /v1/desks; make that one call from your laptop with areadkey, and give the Pi only the id. - A second press while the first is still on its way is ignored.
- A press that times out is not repeated. The call may have gone through, and a second
would call the next person too. The board — or
GET /v1/visits?status=called— says who was called. -
The desk must be on shift: start it in the staff app, or with
PUT /v1/desks/{deskId}/statusfrom a second button.
A nightly export to a spreadsheet#
Every visit of the day in a CSV file, written after closing, for a spreadsheet or a data warehouse.
The key: read only.
Node
// export-visits.mjs — writes today's visits to a CSV file. Run after closing.
import { writeFileSync } from "node:fs";
const API = "https://business.linemeup.in/api/v1";
async function lineMeUp(method, path, body, extraHeaders = {}) {
for (;;) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.LINEMEUP_API_KEY}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? "5");
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
const answer = await res.json();
if (!res.ok) {
const error = new Error(answer.error?.message ?? `Line Me Up answered ${res.status}`);
error.status = res.status;
error.code = answer.error?.code;
error.details = answer.error?.details;
throw error;
}
return answer;
}
}
const COLUMNS = [
"token", "status", "bookingType", "lineId", "customerName", "customerPhone",
"services", "deskId", "slotAt", "createdAt", "calledAt", "startedAt", "endedAt",
];
const cell = (value) => {
const text = value === null || value === undefined ? "" : String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
};
const { today, timeZone } = await lineMeUp("GET", "/business");
const rows = [];
let cursor = null;
do {
const query = new URLSearchParams({ from: today, to: today, limit: "100" });
if (cursor) query.set("cursor", cursor);
const page = await lineMeUp("GET", `/visits?${query}`);
for (const visit of page.data) {
rows.push([
visit.token, visit.status, visit.bookingType, visit.lineId,
visit.customer.name, visit.customer.phone,
visit.services.map((service) => service.name).join("; "),
visit.deskId, visit.slotAt, visit.createdAt, visit.calledAt, visit.startedAt, visit.endedAt,
]);
}
cursor = page.nextCursor;
} while (cursor);
const csv = [COLUMNS, ...rows].map((row) => row.map(cell).join(",")).join("\n");
writeFileSync(`visits-${today}.csv`, csv + "\n");
console.log(`${rows.length} visits on ${today} (${timeZone}) written.`); Python
# export_visits.py — writes today's visits to a CSV file. Run after closing.
# pip install requests
import csv
import os
import time
import requests
API = "https://business.linemeup.in/api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["LINEMEUP_API_KEY"]
def get(path, params=None):
while True:
res = session.get(API + path, params=params, timeout=10)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "5")))
continue
res.raise_for_status()
return res.json()
business = get("/business")
today = business["today"]
with open("visits-" + today + ".csv", "w", newline="", encoding="utf-8") as out:
writer = csv.writer(out)
writer.writerow(["token", "status", "line", "name", "phone", "services",
"created", "called", "started", "ended"])
cursor = None
count = 0
while True:
params = {"from": today, "to": today, "limit": 100}
if cursor:
params["cursor"] = cursor
page = get("/visits", params)
for visit in page["data"]:
writer.writerow([
visit["token"], visit["status"], visit["lineId"],
visit["customer"]["name"] or "", visit["customer"]["phone"] or "",
"; ".join(service["name"] for service in visit["services"]),
visit["createdAt"], visit["calledAt"] or "",
visit["startedAt"] or "", visit["endedAt"] or "",
])
count += 1
cursor = page["nextCursor"]
if not cursor:
break
print(count, "visits on", today, "written.") # crontab -e — 11:30 pm in the business's own time zone, every night.
# linemeup.env holds LINEMEUP_API_KEY=… and is readable by this user only (chmod 600).
CRON_TZ=Asia/Kolkata
30 23 * * * cd /opt/line-me-up && set -a && . ./linemeup.env && set +a && node export-visits.mjs >> export.log 2>&1 -
It asks the business for
todayrather than using the server's date, so a run near midnight exports the right day. -
Run it after the day has closed in the business's zone.
CRON_TZworks with most Linux crons; elsewhere, convert the time yourself. - The file holds names and phone numbers. Store it where the business would keep its own records, not in a shared folder.
-
To export a past day, set
fromandtoto that date instead.