npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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] tsx

2. 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.ts

3. 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 200

The 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 data

Each 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 with basePath).
  • 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 }) reads Authorization: Bearer <token>; your authenticate function turns the token into { userId, organizationId, role } or null.
  • createStaffContext({ userId, organizationId, role, db }) builds the person yourself, for cookies or sessions. Owners, admins and managers count as administrators; pass adminRoles to 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