@ordinatio/scheduling-api
v0.4.0
Published
HTTP API for @ordinatio/scheduling. Every handler takes a Request and returns a Response.
Readme
@ordinatio/scheduling-api
The web routes for @ordinatio/scheduling. Each route is
a function from a standard Request to a standard Response, so it runs in Node, Next.js, Hono, Bun and
anything else that speaks the Fetch API. You supply the database, who is signed in, and anything outside the
database (payment, calendar sync). Everything else has a safe default.
Status: version 0.1.0. It needs @ordinatio/scheduling 0.3.0 or newer.
Words used here
- Host: your program. It owns the server, the database and the sign-in. This package owns the routes.
- Public routes: what a client's browser calls: open times, booking, manage a booking.
- Staff routes: what your team calls: calendars, appointment types, hours, coupons, reports. They need a signed-in person.
- Adapter: a small piece of code you supply for payment, calendar sync or sign-in.
What you need
- Node 22 or newer.
- A Postgres database with nothing in it, and its address. It looks like
postgresql://user:password@localhost:5432/your_empty_database. - npm. (pnpm works too.)
- A shell that understands
export,&and$(...): bash or zsh on macOS or Linux, or WSL on Windows.
Quick start
You will build a small server, add one calendar, book a slot over HTTP, see a second booking for the same slot refused, and see that staff routes need a token. It takes about five minutes.
1. Make a project
Make an empty folder and open a terminal in it. Then run:
npm init -y
npm pkg set type=module
npm install @ordinatio/scheduling @ordinatio/scheduling-api @prisma/[email protected] @prisma/[email protected]
npm install --save-dev [email protected] tsx2. Build the tables
Put your own database address in the first line. The engine brings its own table definitions and one migration.
export DATABASE_URL="postgresql://user:password@localhost:5432/your_empty_database"
npx prisma generate --config node_modules/@ordinatio/scheduling/prisma.config.ts
npx prisma migrate deploy --config node_modules/@ordinatio/scheduling/prisma.config.ts3. Write the server
Save this as server.ts. It connects to the database, builds the API from a host object, and hands each
incoming request to api.handle. Staff sign in with Authorization: Bearer <STAFF_TOKEN>. A real app looks the
token up in its own user store; here there is one token.
import { createHash, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { createBearerStaffResolver, createNodeListener, createSchedulingApi } from '@ordinatio/scheduling-api';
const connectionString = process.env.DATABASE_URL?.trim();
const staffToken = process.env.STAFF_TOKEN?.trim();
if (!connectionString || !staffToken) {
console.error('Set DATABASE_URL and STAFF_TOKEN.');
process.exit(2);
}
const port = Number(process.env.PORT ?? 3000);
const publicUrl = `http://localhost:${port}`;
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
// Compares digests so the time taken does not depend on how much of the token matched.
const digest = (value: string) => createHash('sha256').update(value).digest();
const isStaffToken = (given: string) => timingSafeEqual(digest(given), digest(staffToken));
const api = createSchedulingApi({
host: {
publicDb: async () => prisma,
// Links in booking messages are built from this address.
appUrl: () => publicUrl,
staff: createBearerStaffResolver({
db: () => prisma,
authenticate: async (token) =>
isStaffToken(token) ? { userId: 'owner', organizationId: 'demo-org', role: 'OWNER' } : null,
}),
// This server speaks plain http on your machine. Leave this out when you serve https.
secureCookies: () => false,
},
});
createServer(createNodeListener(api, { publicUrl })).listen(port, () =>
console.log(`Scheduling API on ${publicUrl}/api/scheduling`),
);createNodeListener turns each Node request into a standard Request. It takes the caller's address from the
connection, ignores a Host header or x-forwarded-for the caller wrote, refuses bodies over 1 MB, keeps every
Set-Cookie header, and answers 500 instead of crashing when something throws. The API limits requests by
caller address, so keep it: a hand-written adapter that copies the headers lets a caller dodge the limits.
Behind proxies, pass trustedProxies: n, the number of proxies in front of your server (a load balancer
counts as one). The caller's address is then the nth entry from the right of x-forwarded-for, so a proxy that
appends to the header works as well as one that overwrites it.
This one token is for the quick start only. In your own app, look up a random token of 32 bytes or more in your user store, and store it hashed.
4. Add a calendar
Save this as seed.ts. It adds the organization row, one calendar open on weekdays, one 30-minute appointment
type, and prints the numbers the next step needs.
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import {
createAppointmentType,
createCalendar,
getAvailableSlots,
setWeeklyAvailability,
} from '@ordinatio/scheduling';
const connectionString = process.env.DATABASE_URL?.trim();
if (!connectionString) {
console.error('DATABASE_URL is not set.');
process.exit(2);
}
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
const timezone = 'America/New_York';
const suffix = Date.now().toString(36);
const day = new Date();
day.setUTCHours(12, 0, 0, 0);
day.setUTCDate(day.getUTCDate() + 2);
while ([0, 6].includes(day.getUTCDay())) day.setUTCDate(day.getUTCDate() + 1);
await prisma.organization.upsert({ where: { id: 'demo-org' }, update: {}, create: { id: 'demo-org', name: 'Demo Studio' } });
const calendar = await createCalendar(prisma, {
organizationId: 'demo-org',
name: 'Front desk',
slug: `front-desk-${suffix}`,
type: 'STAFF',
timezone,
});
const appointmentType = await createAppointmentType(prisma, {
organizationId: 'demo-org',
name: 'Consultation',
slug: `consultation-${suffix}`,
duration: 30,
calendarIds: [calendar.id],
});
await setWeeklyAvailability(
prisma,
calendar.id,
[1, 2, 3, 4, 5].map((dayOfWeek) => ({ dayOfWeek, startTime: '09:00', endTime: '17:00', minNotice: 0 })),
);
const { slots } = await getAvailableSlots(prisma, { calendarId: calendar.id, date: day, duration: 30, timezone });
if (slots.length === 0) throw new Error('No open slots were found for the demo day.');
console.log(`CALENDAR=${calendar.id}`);
console.log(`TYPE=${appointmentType.id}`);
console.log(`DAY=${day.toISOString().slice(0, 10)}`);
console.log(`START=${slots[0].start.toISOString()}`);
await prisma.$disconnect();5. Run it and try it
This starts the server, then calls it the way a booking page would. Each curl prints one line.
(
export STAFF_TOKEN="a-long-random-text"
export PORT=3100
npx tsx seed.ts > seed.out || { cat seed.out; exit 1; }
set -a; . ./seed.out; set +a
node --import tsx server.ts > server.log 2>&1 &
SERVER_PID=$!
trap 'kill $SERVER_PID' EXIT
for attempt in $(seq 1 30); do
kill -0 $SERVER_PID || { echo "The server stopped. Is port $PORT in use? See server.log."; exit 1; }
curl -s -o /dev/null "http://localhost:$PORT/api/scheduling/widget-init" && break
sleep 1
done
API="http://localhost:$PORT/api/scheduling"
BODY="{\"calendarId\":\"$CALENDAR\",\"typeId\":\"$TYPE\",\"startTime\":\"$START\",\"timezone\":\"America/New_York\",\"clientName\":\"Jane Smith\",\"clientEmail\":\"[email protected]\"}"
curl -s -o /dev/null -w "Open times on the day: HTTP %{http_code}\n" "$API/availability?calendarId=$CALENDAR&typeId=$TYPE&date=$DAY&timezone=America/New_York"
curl -s -o /dev/null -w "First booking: HTTP %{http_code}\n" -X POST -H "content-type: application/json" -d "$BODY" "$API/appointments"
curl -s -o /dev/null -w "Same slot again: HTTP %{http_code}\n" -X POST -H "content-type: application/json" -d "$BODY" "$API/appointments"
curl -s -o /dev/null -w "Calendars without a token: HTTP %{http_code}\n" "$API/admin/calendars"
curl -s -o /dev/null -w "Calendars with the token: HTTP %{http_code}\n" -H "authorization: Bearer $STAFF_TOKEN" "$API/admin/calendars"
)The round brackets run all of this in its own shell, so the server stops when the block ends and a failure cannot
close your terminal. The server's own output goes to server.log.
It prints this:
Open times on the day: HTTP 200
First booking: HTTP 201
Same slot again: HTTP 409
Calendars without a token: HTTP 401
Calendars with the token: HTTP 200The second booking is refused by the database, not only by the route, so two people who book the same slot at the same moment cannot both win.
Check that this README still works
From this package's folder, run bash scripts/check-readme.sh. It follows the steps above in an empty folder
against a scratch Postgres database and compares the output with the block above. It needs a local Postgres
that your user can create databases in.
The host object
createSchedulingApi({ host }) takes one object. Only publicDb is required.
| Member | What it does | Default |
| --- | --- | --- |
| publicDb() | The database. | required |
| appUrl() | The web address of your server. Booking needs it to build links in messages. | none: booking answers 500 |
| staff | Who is signed in. createBearerStaffResolver reads a bearer token; createStaffContext builds the person for your own sign-in. | nobody: staff routes answer 401 |
| payments | Charges, verifies and refunds payments. | none: a type that needs payment cannot be booked |
| calendarSync | Keeps Google or Outlook in step. | nothing is synced |
| rateLimit | Counts requests, by caller address. Return a response (normally 429) to stop one. Your server must set the address from the connection (see createNodeListener). | in-memory, per process |
| secureCookies() | Whether the short-lived sign-in cookies travel only over https. | yes: on plain http the cookies do not stick |
| cache, availabilityCache | Stored answers. | in-memory, per process |
| publicBooking, organizations, tenantDb | Find the organization and database of a request. | one organization |
| events, activity | Tell your screens or activity feed what changed. | nothing |
| internalAccess, workerCommands | The background worker's own routes. | closed |
| calendarConnectors | The Google and Outlook sign-in flows. | not configured |
Everything else on SchedulingHost is documented in its type. If a member you do not set is needed by a route,
the route says so in its answer instead of guessing.
Webhooks
Set webhooks: {} on the host to tell other systems about bookings. Staff then have these routes, all for
admins:
| Route | What it does |
|---|---|
| GET /admin/webhooks | Lists the endpoints and the event names. Never shows a secret. |
| POST /admin/webhooks | Adds one: { "url": "https://...", "eventTypes": ["appointment.booked"], "description": "CRM" }. The answer holds the signing secret; it is shown once. |
| DELETE /admin/webhooks/[id] | Removes an endpoint with its deliveries. |
| POST /admin/webhooks/[id]/test | Sends one signed webhook.test event now and reports what the receiver answered. |
| GET /admin/webhooks/[id]/deliveries | The delivery log, with every try. ?status=DEAD_LETTER filters. |
| POST /admin/webhook-deliveries/[id]/retry | Sends a delivery that ran out of tries again. |
Events are queued for you. Sending is done by a timer you run: call deliverDueWebhooks(db) from
@ordinatio/scheduling every few seconds, or have your worker POST /internal/webhooks/deliver (it needs
host.internalAccess). Without webhooks on the host the routes answer 501 and nothing is queued. In local
development, webhooks: { allowInsecureTargets: true } lets an endpoint point at http://localhost. The engine
README explains the retry schedule and how a receiver checks the signature. The events are appointment.booked,
appointment.rescheduled, appointment.cancelled, appointment.no_show and waitlist.offered; reminder.sent is sent when
the worker that sends reminders passes webhooks: true to processPendingNotifications.
Waitlist
Set waitlist: {} on the host to let clients wait for a full slot or day. A cancelled, rejected or moved booking offers
the slot it held to the person who has waited longest.
| Route | Who | What it does |
|---|---|---|
| POST /waitlist | public | Joins: { "typeId": "...", "day": "2026-11-03", "slotStart": "...", "clientName": "...", "clientEmail": "..." } (slotStart, calendarId and orgSlug are optional). Refused with 409 while the slot or day still has room. The answer holds the client's own token. |
| DELETE /waitlist?token= | public | Leaves the list. |
| GET /waitlist/offer?token= | public | What was offered, until when, and the name the client gave. No email or phone. |
| POST /waitlist/offer/accept | public | { "token": "..." } books the offered slot through the normal booking route, so your rules, messages and calendar sync apply. 409 SLOT_UNAVAILABLE if someone booked it first; the client keeps their place. |
| GET /admin/waitlist | admin | The people waiting, oldest first. ?status= filters. |
| DELETE /admin/waitlist/[id] | admin | Takes someone off. |
| POST /internal/waitlist/sweep | worker | Ends offers whose time ran out, settles an accept that never finished (booked, or back in line), and offers each freed slot to the next person. Call it every minute or so; it answers { lapsed, released, booked, reoffered } (offers ended, accepts sent back in line, accepts found already booked, slots offered onward). A person sent back in line from a stuck accept is first in line again, so they may be offered the same slot. It serves the host's public database; a host with a database per organization calls expireWaitlistOffers and offerFreedSlot from the engine itself for each one. |
host.waitlist.notify(offer) is where you send the client their link (put offer.token in it and open the offer page, which
calls the two offer routes). The waitlist.offered webhook announces the same thing. host.waitlist.offerMinutes sets the
length of an offer (default 30; never past the start of the slot). An offer is first dibs, not a hold. A type that needs payment
or with required intake questions has no waitlist. A client who leaves, or staff who remove someone, while they hold an offer
passes the slot to the next person. If accepting fails because the server is busy the offer stays open; if the slot was
taken the client keeps their place; if a booking rule refuses them they come off the list and the slot is re-offered.
Joining does not check that the email belongs to the person joining, and the waitlist.offered webhook body contains the
client's private token (visible to admins in the delivery log), so protect the join page and the endpoint accordingly.
Without waitlist on the host the routes answer 501.
Routing forms
Set routingForms: {} on the host to let admins build short forms that send a client to the right appointment type or
calendar. The engine README explains question types, operators and how a form is checked.
| Route | Who | What it does |
|---|---|---|
| GET /routing-forms?slug= | public | The form's questions. Never the rules or the fallback. orgSlug names the organization when one database holds several. |
| POST /routing-forms/route | public | { "slug": "...", "answers": { "service": "Suit" } } (send answers: {} when every question is optional and unanswered) answers with { destination, message, matchedRuleId, usedFallback }. destination has the type and calendar ids, slugs and names to build a booking link from; it is null when the form's fallback is a message. 400 with details when the answers do not fit. |
| GET /admin/routing-forms, POST /admin/routing-forms | admin | List the forms; save a new one. A form with any problem is refused with a 400 whose details list every problem with its place (rules[1].conditions[0].questionId). |
| GET /admin/routing-forms/[id], PUT, DELETE | admin | Read one in full; change it (the whole result is checked again); remove it. |
| POST /admin/routing-forms/[id]/test | admin | { "answers": { ... } } tries sample answers, even on a form that is switched off, and lists every rule that matched. |
A slug is unique across the organizations of one database, so a link by slug alone is not ambiguous; a host with a database per
organization passes orgSlug. The public routes answer 404 for an unknown, switched-off or ambiguous form, and the staff routes
answer 409 for a slug already in use in the organization (SCHED_1202) or the 50-form limit (SCHED_1204) and 404 for another organization's form. The test route's
answer adds allMatchedRuleIds, every rule the answers satisfy. matchedRuleId is the rule id the admin chose, so pick ids that are
safe to show. Routing does not stop a client from booking something else: it only suggests where to go. Without
routingForms on the host the routes answer 501.
Organization brand
Set brand: {} on the host to let each organization carry a name, logo, colours, font and footer on its booking page and in its
messages, and a sender identity. The engine README explains what each field accepts.
| Route | Who | What it does |
|---|---|---|
| GET /brand | public | The brand a visitor may see: name, logo, colours, font, footer. Never an email address. orgSlug names the organization when one database holds several; with none named, the brand of the only organization that has one. 404 when there is none. |
| GET /admin/brand, PUT /admin/brand, DELETE /admin/brand | admin | Read the whole brand including its email settings ({ brand: null } when none); replace it; remove it. A brand with any problem is a 400 whose details list every problem with its field. |
Sending from the organization's own domain. fromEmail is accepted only if the host says it may send from that domain:
brand: { isSendingDomainVerified: async (domain, organizationId) => myMailProvider.isVerified(domain) }With no isSendingDomainVerified, every fromEmail is refused (replyToEmail and the rest are still fine). Your message sender then
decides how to use the identity (see the engine README). Messages queued when a booking is made, moved or cancelled carry the brand
fields for templates, and the manual follow-up and reminder texts sign with the brand name when a calendar has no staff name.
The booking widget loads GET /brand by itself and applies it; a colour the page sets in theme always wins over the brand's.
icalUidDomain() on the host sets the domain in calendar-file event ids (default ordinatio-scheduling); once calendars have subscribed, keep it unchanged.
Without brand on the host the routes answer 501.
Data export and erasure
Set privacy: {} on the host to let an admin export or erase everything held about one person. The engine README explains what
each does, what it keeps, and what it does not reach. The person is named in the request body, never in the address, so an email
does not end up in access logs.
| Route | Who | What it does |
|---|---|---|
| POST /admin/privacy/export | admin | { "email": "..." } and/or { "clientId": "..." }. Answers with the export as a JSON file to save (content-disposition: attachment, never cached). |
| POST /admin/privacy/erase | admin | The same body. Without "confirm": true it only previews: { erased: false, preview: { counts, blockers, canErase, warnings } }. With it, erases and answers { erased: true, counts }. 409 (SCHED_1301) while the person has upcoming bookings or an open waitlist offer, with the numbers in details.blockers. |
| GET /admin/privacy/requests | admin | The log of exports and erasures: when, who asked, how many rows. It holds no names or addresses. |
A body that names nobody, names an invalid address, or carries other fields is a 400. Both actions are rate limited and written to
the host's activity feed with counts only. Without privacy on the host the routes answer 501.
Agent tools
An AI agent can run scheduling through the same routes. Import the tool descriptions and hand them to any agent runner:
import { createSchedulingAgentTools } from '@ordinatio/scheduling-api/agent';
const tools = createSchedulingAgentTools({ basePath: '/api/scheduling' }); // readOnly: true leaves out everything that changes dataEach tool maps to a route, says when to use it, lists its parameters and gives an example. Reads run freely. Every tool that
changes something, sends a message or moves money carries an requiresApproval entry, so the runner asks a person first. The tools
call your routes. Tools on staff routes use the signed-in person's session, so the usual sign-in, role and organization checks apply. Tools on the public booking routes (auth: 'none': availability, appointment types, capacity and booking) behave exactly like the public widget: no staff sign-in, rate limited by address, and without the staff member's calendar visibility. Give those to an agent only if that is what you want. Parameter types are string, number, boolean, string[] and json-object. The booking tool's intakeResponses is a json-object: a runner that turns types it does not know into text cannot pass intake answers, so it needs to offer that type to the model as an object (@ordinatio/agent does, except for Gemini, where booking with intake answers does not work yet).
Some routes are left out on purpose, such as the public booking widget, link-token self-service, worker entry points and browser
sign-in steps; SCHEDULING_AGENT_EXCLUDED gives the reason for each. SCHEDULING_AGENT_PENDING lists the routes that do not have a
tool yet (calendars, appointment types, availability rules, recurring series, coupons, packages, policy, webhooks, routing forms,
waitlist, privacy, analytics). That list only gets shorter: a test fails if a route is in none of the three places.
Serving the routes
- One handler:
api.handle(request)serves every route under/api/scheduling(change it withbasePath). - One file per route, in a framework that routes by file such as Next.js:
export const POST = api.handler('POST', '/coupons/validate'); - Request shapes for your own browser code:
import { ... } from '@ordinatio/scheduling-api/schemas'.
Sign-in
Staff routes ask the host who is calling. The two helpers cover the common cases:
createBearerStaffResolver({ db, authenticate })readsAuthorization: Bearer <token>; yourauthenticatefunction turns the token into{ userId, organizationId, role }ornull.createStaffContext({ userId, organizationId, role, db })builds the person yourself, for cookies or sessions. Owners, admins and managers count as administrators; passadminRolesto change that.
Both give the person access to every calendar of the organization. If you have groups that should see only
some calendars, build your own StaffContext and implement visibleCalendars, visibleCalendarIds,
canSeeCalendar and canSeeAppointment.
License
MIT
