# TypeScript SDK

Reference for @clndr-pro/sdk, the typed client for the clndr.pro API, with every method, the errors it throws, and the plain HTTP calls underneath.

`@clndr-pro/sdk` is a thin, typed wrapper over the [REST API](/api-reference). 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.

```bash
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:

```ts title="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`](/react/components#clndrprovider) create the client for you. Elsewhere:

```ts
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:

**apiKey**

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

**[baseUrl](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.

**typeof fetch**

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()`

```ts
list(): Promise<BookingPage[]>
```

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

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

### `bookingPages.get(slug)`

```ts
get(slug: string): Promise<BookingPageDetail>
```

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

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

### `bookingPages.getSlots(slug, date)`

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

Open slots on one day: `GET /booking-pages/{slug}/slots?date=` ([reference](/api-reference/operations/listSlots)). 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.

```ts
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)`

```ts
create(input: CreateBookingInput): Promise<Booking>
```

`POST /bookings` ([reference](/api-reference/operations/createBooking)). Works with either key type. Pass a slot's `start` and `end` unchanged:

```ts
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?)`

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

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

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

### `bookings.get(id)`

```ts
get(id: string): Promise<Booking>
```

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

### `bookings.update(id, patch)`

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

`PATCH /bookings/{id}` ([reference](/api-reference/operations/updateBooking)). 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](/tutorials/approvals-dashboard) shows how.

### `bookings.cancel(id)`

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

`DELETE /bookings/{id}` ([reference](/api-reference/operations/cancelBooking)). 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.

```ts title="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](/errors) 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:

```ts title="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:

```bash
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](/api-reference) has the full schemas, and [`/openapi.json`](/openapi.json) is the spec to generate a client in another language.
