Developers
Contents
On this page

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 — on your server
// 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 the page
<!-- 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 422 names 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 — reads Line Me Up, serves /board.json
// 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 screen
<!-- 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 — the receiver
// 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 200 before 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.
  • crm and store are yours: an upsert into the CRM, and a table of event ids and each visit's last updatedAt.
reconcile.mjs — every night
// 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
# 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 a read key, 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}/status from 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.

export-visits — one page of 100 at a time

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.")
Run it every night
# 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 today rather 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_TZ works 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 from and to to that date instead.