Docs
What you can connect to today.
Scandum · 2 September 2026
Three things: a script tag that puts the web chat door on your site, the scheduling API a voice agent reads a diary and books through, and the webhook every lead is posted to. Each is documented from the code that serves it.
Widget embed
How do I put the web chat door on my site?
One script tag before the closing body tag. The workspace key is in the evening digest email your workspace sends; paste it into data-workspace. The widget writes to the same customer record the phone door writes to, in the visitor's language.
<script src="https://scandum.com/widget.js" data-workspace="YOUR_WORKSPACE_KEY"></script>The web chat door is new and in the lab; the tag shape above is the one that ships with it. Your workspace key is a secret to your business, not to the visitor: it identifies the workspace, and the widget can only write, never read.
Speed-to-Lead
Is there an API to push a lead so the qualifier calls it?
Not yet as a public endpoint. POST /api/v1/leads is not implemented. Today a lead reaches the Lead Qualifier through the suite's own forms and landing pages, and reaches you through the webhook below. What is public and stable is the scheduling API: the diary a voice agent reads and books into.
When a leads endpoint ships it will be documented here first, with the same honesty: shape, headers, failure codes. Until then, do not build against a URL this page does not print.
Scheduling API · /api/v1
How does a voice agent read a real diary and book into it?
Four endpoints under /api/v1, one key per calendar. Read availability before offering any time, book with date, time, name and phone, read or cancel by the spoken reference. Every response carries ok; every failure carries a machine-readable error and a message written to be said aloud.
Authentication
Send the calendar's key as a bearer token. The key is the calendar; there is no calendar id in any path. A platform whose tool builder only offers plain headers can send x-api-key instead. With no calendars configured every route answers 503 not_configured; a wrong key answers 401 unauthorized.
Authorization: Bearer <apiKey>
# or
x-api-key: <apiKey>All dates are YYYY-MM-DD and times HH:mm on a 24-hour clock in the calendar's own timezone; any field named …At is absolute UTC ISO. Writes are limited to 120 in five minutes per key (429 rate_limited).
GET /api/v1/calendar
Who am I talking to and when are they open. Call it on deployment, not on every call: hours, timezone, slot length, notice and horizon, plus the same facts as spoken sentences so none of it is hard-coded into a prompt.
curl -H "Authorization: Bearer $KEY" https://scandum.com/api/v1/calendar
{ "ok": true,
"calendar": { "id": "…", "name": "…", "timezone": "Asia/Kolkata", "slotMinutes": 30, … },
"now": { "date": "2026-09-02", "time": "10:14", "weekday": "Wednesday", "timezone": "Asia/Kolkata" },
"spoken": { "today": "…", "hours": ["Monday 9:30 am to 1 pm and 2 pm to 7 pm", …],
"notice": "Appointments need at least 30 minutes' notice.",
"horizon": "The diary is open for the next 30 days." } }GET /api/v1/availability
Free slots. The call to make before offering anybody a time, and the only times an agent may offer are the ones in days[].slots.
The next N days from today, open or closed. Whole number.
An explicit window.
One day.
Minutes, for an appointment longer than the default slot. Also accepted as durationMinutes.
curl -H "Authorization: Bearer $KEY" "https://scandum.com/api/v1/availability?days=3"
{ "ok": true,
"calendar": { "id": "…", "name": "…", "timezone": "Asia/Kolkata", "slotMinutes": 30 },
"from": "2026-09-02", "to": "2026-09-04", "durationMinutes": 30,
"note": "Offer only the times in `days[].slots`. …",
"totalOpen": 30,
"days": [ { "date": "2026-09-02", "weekday": "Wednesday", "closed": false,
"slots": [ { "date": "2026-09-02", "time": "11:00",
"startsAt": "2026-09-02T05:30:00.000Z", "endsAt": "2026-09-02T06:00:00.000Z",
"remaining": 1 } ] } ],
"soonest": [ { "date": "2026-09-02", "time": "11:00", "startsAt": "…", "spoken": "Wednesday 2 September at 11 am" } ] }Asks beyond the horizon are clamped, not refused. closedReason on a closed day is weekly or holiday, so the agent can say “we are shut Sundays” rather than “no availability”. Up to six of the soonest slots come pre-phrased in soonest[].spoken.
POST /api/v1/appointments
Book one. Required: date, time, name, phone. Optional: email, reason, notes, durationMinutes (a multiple of the slot length), source. The phone is stored as given and matched on its last ten digits.
curl -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
https://scandum.com/api/v1/appointments \
-d '{"date":"2026-09-03","time":"11:00","name":"Priya Sharma","phone":"+91 98765 43210","reason":"follow-up"}'
201
{ "ok": true,
"appointment": { "id": "apt_…", "reference": "QYK9PP", "date": "2026-09-03", "time": "11:00",
"durationMinutes": 30, "startsAt": "2026-09-03T05:30:00.000Z", "status": "confirmed", … },
"spoken": "Booked for Thursday 3 September at 11 am. The reference is Q Y K 9 P P." }The reference is drawn from an alphabet with no O, 0, I, 1, S or 5, because it is read down a phone line and written by someone holding a pen.
| error | status | means |
|---|---|---|
| slot_taken | 409 | Gone since the availability call. Carries alternatives, the nearest openings; offer one. |
| outside_hours | 409 | Not a slot that day, for example inside the lunch gap. |
| closed | 409 | Weekly closure or a holiday. |
| too_soon | 409 | Inside the notice window. |
| beyond_horizon | 400 | Further ahead than the diary opens; the message names the last bookable date. |
| bad_duration | 400 | Not a whole number, or not a multiple of the slot length. |
| bad_time | 400 | Time not in HH:mm. |
| invalid_name / invalid_phone | 400 | Ask the caller again. |
| bad_json | 400 | The body was not a JSON object. |
| unauthorized | 401 | Key missing or wrong. |
| not_found | 404 | No appointment with that reference. |
| rate_limited | 429 | More than 120 writes in five minutes on one key. |
| not_configured | 503 | This deployment has no calendars set up. |
GET /api/v1/appointments
Find appointments. Filters: phone (matched on the last ten digits), date, from and to, status (confirmed by default, or cancelled or all), limit (up to three digits). Each appointment comes with a spoken line.
curl -H "Authorization: Bearer $KEY" "https://scandum.com/api/v1/appointments?phone=9876543210"
{ "ok": true, "count": 1, "appointments": [ { …, "spoken": "Thursday 3 September at 11 am" } ] }GET, PATCH, DELETE /api/v1/appointments/[ref]
[ref] is either the spoken reference (case and hyphens ignored) or the internal apt_… id. GET reads one back. PATCH with date and time moves it and keeps the reference; PATCH with {"status":"cancelled"} cancels it, for tool builders that only emit PATCH. DELETE cancels it, with an optional ?reason=. Cancelling twice is not an error: an agent retrying after a dropped connection must not hear a failure the second time.
curl -H "Authorization: Bearer $KEY" https://scandum.com/api/v1/appointments/QYK9PP
curl -X PATCH -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
https://scandum.com/api/v1/appointments/QYK9PP -d '{"date":"2026-09-04","time":"16:00"}'
# → { "ok": true, "appointment": { … }, "spoken": "Moved to Friday 4 September at 4 pm. The reference is the same." }
curl -X DELETE -H "Authorization: Bearer $KEY" \
"https://scandum.com/api/v1/appointments/QYK9PP?reason=caller+changed+their+mind"
# → { "ok": true, "appointment": { …, "status": "cancelled" }, "spoken": "That's cancelled." }Not the demo desk
The board on the home page runs on /api/bookings/*, a shared sandbox that shows one day and forgets it a few hours later; its write route takes an x-agent-key header. It shares no storage and no rules with /api/v1. Never point a client's agent at it.
Webhooks
Where does a lead go the moment it arrives?
Every form submission is written to the store and, when LEAD_WEBHOOK_URL is set, posted to that URL as JSON with a four-second timeout. The body carries a ready-made text line, which Slack renders as is, and the full lead object, which Zapier and n8n keep field by field.
{
"text": "New lead — quote\nPriya Sharma · Sharma Dental — priya@example.com / +919876543210\nvia quotes-form\n“Twenty quotes from August.”",
"lead": {
"id": "ld_3f9a1c2b",
"at": "2026-09-02T04:44:10.000Z",
"kind": "contact", // or "demo-call"
"name": "Priya Sharma",
"email": "priya@example.com", // optional
"phone": "+919876543210", // optional, +91 and ten digits
"company": "Sharma Dental", // optional
"message": "Twenty quotes from August.", // optional
"intent": "quote", // discovery-call | demo-waitlist | product-waitlist | careers | quote
"industry": "…", // demo-call only
"source": "quotes-form", // the page or component that sent it
"ip": "…"
}
}None. The webhook is tried once, in parallel with the store, with a four-second timeout.
Any 2xx. A non-2xx is logged as rejected and counts as not delivered.
Not a failure: the store is still a sink. When neither the store nor the webhook takes a lead, the contact API answers 502 not_delivered so the visitor is told to email instead of thanked for a message nobody received.
Phone numbers and emails are masked in logs; a full number is never written.
There is no webhook on appointment writes yet. The scheduling API holds the diary; a webhook-out on write is the natural next step and does not exist today.
Questions about any of this: contact@scandum.com. The AI disclosure every call opens with is on the legal page.