TypeScript SDK

@clndr-pro/sdk is a thin, typed wrapper over the REST API. It has no dependencies and uses the runtime's own fetch, so the same import works in Node, Edge functions, Bun, Deno and browsers. @clndr-pro/react is built on it and re-exports the Clndr class, but on a server you only need this package.

npm install @clndr-pro/sdk

Use 0.1.5 or later. Earlier versions default to https://clndr.pro, which redirects to www and drops your key (every call fails with 401 Missing API key), and their CommonJS entry point is missing, so require('@clndr-pro/sdk') throws. On an older version, pass baseUrl: 'https://www.clndr.pro' and import it as ESM.

Create a client

On a server, use your secret key. Keep the client in one module and mark it server-only, so a stray import from a client component fails the build instead of shipping the key:

lib/clndr.ts
import 'server-only';
import { Clndr } from '@clndr-pro/sdk';
 
export const clndr = new Clndr(process.env.CLNDR_SECRET_KEY!);

server-only is a separate npm package (npm install server-only). Next.js turns any client-side import of it into a build error.

In a browser, use a publishable key. If you're in React, let ClndrProvider create the client for you. Elsewhere:

import { Clndr } from '@clndr-pro/sdk';
 
const clndr = new Clndr({ apiKey: 'clndr_pk_...' });

Options

The constructor takes the key on its own, or an options object:

apiKeystringbodyrequired

A secret (clndr_sk_…) or publishable (clndr_pk_…) key. The constructor throws if it's empty.

baseUrlstringbodydefault: https://www.clndr.pro

The clndr.pro origin, without /api/v1. Trailing slashes are stripped. Change it only to point at a local or self-hosted instance.

fetchtypeof fetchbody

A fetch implementation to use instead of the global one. Handy for tests, request logging, or adding a timeout.

clndr.keyType is 'publishable' when the key starts with clndr_pk_ and 'secret' otherwise. It's read from the prefix, not checked with the server.

Booking pages

bookingPages.list()

list(): Promise<BookingPage[]>

Every booking page on the key's account, newest first: GET /booking-pages (reference). Inactive and private pages are included, so filter before showing the list to guests:

const pages = (await clndr.bookingPages.list()).filter((p) => p.is_active && p.visibility === 'public');

bookingPages.get(slug)

get(slug: string): Promise<BookingPageDetail>

One active page, its host's public profile and its questions: GET /booking-pages/{slug} (reference). Throws a ClndrError with status 404 when the slug doesn't exist on the account or the page is inactive.

const { bookingPage, userProfile, questions } = await clndr.bookingPages.get('intro-call');
console.log(bookingPage.id, userProfile.timezone, questions.length);

bookingPages.getSlots(slug, date)

getSlots(slug: string, date: string | Date): Promise<TimeSlot[]>

Open slots on one day: GET /booking-pages/{slug}/slots?date= (reference). The day is a calendar day in the host's timezone.

  • A string is sent as is. Pass YYYY-MM-DD.
  • A Date is turned into its local calendar date (getFullYear, getMonth, getDate) and sent as YYYY-MM-DD. Build it with new Date(2026, 9, 15), not new Date('2026-10-15'): the second form is UTC midnight, which is still the 14th in the Americas.
const slots = await clndr.bookingPages.getSlots('intro-call', '2026-10-15');
// [{ start: '2026-10-15T08:00:00.000Z', end: '2026-10-15T08:30:00.000Z' }, …]

Slots that have already started aren't returned, and days beyond the page's max_days_ahead come back empty.

Bookings

bookings.create(input)

create(input: CreateBookingInput): Promise<Booking>

POST /bookings (reference). Works with either key type. Pass a slot's start and end unchanged:

const booking = await clndr.bookings.create({
  bookingPageId: bookingPage.id,
  guestName: 'Grace Hopper',
  guestEmail: 'grace@example.com',
  startTime: slots[0].start,
  endTime: slots[0].end,
  responses: [{ questionId: questions[0].id, answer: 'Moving our sales demos over.' }],
});
 
booking.status; // 'confirmed' on a direct page, 'pending' on an approval page

The returned booking doesn't include the answers. Read them back with bookings.get.

bookings.list(params?)

list(params?: { status?: 'pending' | 'confirmed' | 'cancelled'; limit?: number }): Promise<Booking[]>

GET /bookings (reference). Secret keys only. Sorted by start_time, latest first. limit defaults to 50 and is capped at 200; there's no cursor.

const pending = await clndr.bookings.list({ status: 'pending', limit: 100 });

bookings.get(id)

get(id: string): Promise<Booking>

GET /bookings/{id} (reference). Secret keys only. The response also carries meeting_responses, the guest's answers, which the SDK's Booking type doesn't declare. Data types has an interface to cast to.

bookings.update(id, patch)

update(id: string, patch: { status: 'confirmed' | 'cancelled'; reason?: string }): Promise<Booking>

PATCH /bookings/{id} (reference). Secret keys only. Approve a pending booking with { status: 'confirmed' }, decline with { status: 'cancelled', reason }.

Confirming through the API creates the Google Calendar event and Meet link if the page uses Meet links, and Google emails the guest the invite. clndr.pro doesn't send its own confirmation email for API confirmations, so on pages without Meet links, tell the guest yourself. Approve bookings in your admin shows how.

bookings.cancel(id)

cancel(id: string): Promise<{ success: true }>

DELETE /bookings/{id} (reference). Secret keys only. Cancels the booking and deletes its Google Calendar event; Google tells the guest. Unlike update, it never stores a reason.

Errors

Any non-2xx response throws a ClndrError. Its message is the API's error string, status is the HTTP status, and data is the parsed response body.

lib/book.ts
import 'server-only';
import { ClndrError, type CreateBookingInput } from '@clndr-pro/sdk';
import { clndr } from '@/lib/clndr';
 
export async function book(input: CreateBookingInput) {
  try {
    return { booking: await clndr.bookings.create(input) };
  } catch (err) {
    if (!(err instanceof ClndrError)) throw err; // network failure, timeout, …
 
    switch (err.status) {
      case 400:
        // A malformed request: missing field, bad email, unparseable time.
        return { error: err.message };
      case 409:
        // The slot was taken (or, with a publishable key, isn't an open slot).
        // Refetch the slots and let the guest pick another.
        return { error: 'That time was just taken. Pick another.' };
      case 401:
      case 403:
        // Revoked key, missing scope, page on another account. Your bug, not the guest's.
        console.error('clndr.pro key problem:', err.message);
        return { error: 'Booking is unavailable right now.' };
      case 429:
        // The key's per-minute limit, or this guest email's 5 attempts this clock hour.
        return { error: 'Too many attempts. Try again later.' };
      default:
        return { error: 'Something went wrong. Please try again.' };
    }
  }
}

Network errors and timeouts aren't wrapped: you get whatever your fetch throws, usually a TypeError. Errors and rate limits lists every message the API sends.

Runtimes

Node 18 and later, Vercel and Cloudflare edge runtimes, Bun, Deno and modern browsers: anywhere with a global fetch. From 0.1.5 the package ships both ESM (import) and CommonJS (require) builds, with type definitions for each.

Testing

Pass a fetch that answers from fixtures, and nothing touches the network:

booking.test.ts
import { describe, expect, it } from 'vitest';
import { Clndr } from '@clndr-pro/sdk';
 
function fakeFetch(routes: Record<string, unknown>): typeof fetch {
  return async (input) => {
    const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
    const body = routes[new URL(href).pathname];
    return body === undefined
      ? new Response(JSON.stringify({ error: 'Booking page not found' }), { status: 404 })
      : new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
  };
}
 
describe('slots', () => {
  it('returns the API data', async () => {
    const clndr = new Clndr({
      apiKey: 'clndr_sk_test',
      fetch: fakeFetch({
        '/api/v1/booking-pages/intro-call/slots': {
          data: [{ start: '2026-10-15T08:00:00.000Z', end: '2026-10-15T08:30:00.000Z' }],
        },
      }),
    });
    await expect(clndr.bookingPages.getSlots('intro-call', '2026-10-15')).resolves.toHaveLength(1);
  });
});

The same hook adds a timeout in production: fetch: (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(8000) }).

Without the SDK

Every method above is one HTTP request with a bearer token. From a terminal:

export CLNDR_KEY=clndr_sk_...
export CLNDR_API=https://www.clndr.pro/api/v1
 
# Booking pages and slots (any key)
curl -H "Authorization: Bearer $CLNDR_KEY" "$CLNDR_API/booking-pages"
curl -H "Authorization: Bearer $CLNDR_KEY" "$CLNDR_API/booking-pages/intro-call"
curl -H "Authorization: Bearer $CLNDR_KEY" "$CLNDR_API/booking-pages/intro-call/slots?date=2026-10-15"
 
# Create a booking (any key)
curl -X POST "$CLNDR_API/bookings" \
  -H "Authorization: Bearer $CLNDR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "bookingPageId": "8b6f2c1e-4a1d-4f0b-9a63-2f0c5d7e9b14",
    "guestName": "Grace Hopper",
    "guestEmail": "grace@example.com",
    "startTime": "2026-10-15T08:40:00.000Z",
    "endTime": "2026-10-15T09:10:00.000Z"
  }'
 
# Read, confirm and cancel bookings (secret key)
curl -H "Authorization: Bearer $CLNDR_KEY" "$CLNDR_API/bookings?status=pending&limit=20"
curl -H "Authorization: Bearer $CLNDR_KEY" "$CLNDR_API/bookings/c3a7e5d1-2b9f-4c6e-8a1d-7f4b0e2c9a58"
curl -X PATCH "$CLNDR_API/bookings/c3a7e5d1-2b9f-4c6e-8a1d-7f4b0e2c9a58" \
  -H "Authorization: Bearer $CLNDR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "cancelled", "reason": "Travelling that week." }'
curl -X DELETE -H "Authorization: Bearer $CLNDR_KEY" \
  "$CLNDR_API/bookings/c3a7e5d1-2b9f-4c6e-8a1d-7f4b0e2c9a58"

Success bodies are wrapped in data (except DELETE, which returns { "success": true }), and errors are { "error": "…" }. The API reference has the full schemas, and /openapi.json is the spec to generate a client in another language.