Sync bookings to your database

clndr.pro doesn't send webhooks yet. If you want bookings in your own database (for a CRM, a reporting dashboard, or to join them against your users), you pull them. Two signals cover it:

  1. The booking form tells you. <BookingInline onBooked> and the embed's onBookingSuccessful fire in the guest's browser the moment a booking succeeds. Post the id to your server and store it straight away.
  2. A cron job catches everything else. Status changes made in the clndr.pro dashboard, approvals, cancellations by the host, bookings made on the hosted page: none of those reach your site. A job that lists bookings every ten minutes does.
Two paths into your table

The examples use Drizzle on Postgres. The SQL at the end does the same without an ORM.

The table

Store the fields you'll query, plus clndr.pro's updated_at, which changes on every status change and tells you whether your copy is stale.

db/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
 
export const clndrBookings = pgTable('clndr_bookings', {
  id: uuid('id').primaryKey(), // clndr.pro's booking id
  bookingPageId: uuid('booking_page_id').notNull(),
  guestName: text('guest_name').notNull(),
  guestEmail: text('guest_email').notNull(),
  startTime: timestamp('start_time', { withTimezone: true }).notNull(),
  endTime: timestamp('end_time', { withTimezone: true }).notNull(),
  status: text('status').notNull(), // pending | confirmed | cancelled
  meetLink: text('meet_link'),
  cancellationReason: text('cancellation_reason'),
  clndrUpdatedAt: timestamp('clndr_updated_at', { withTimezone: true }).notNull(),
  syncedAt: timestamp('synced_at', { withTimezone: true }).notNull().defaultNow(),
});

The sync

upsertBookings writes any bookings whose updated_at is newer than your copy and hands each change to a hook. syncBookings lists all three statuses (200 each, the API's maximum) and upserts them.

lib/clndr-sync.ts
import 'server-only';
import { and, gt, inArray, sql } from 'drizzle-orm';
import { ClndrError } from '@clndr-pro/sdk';
import { db } from '@/db';
import { clndrBookings } from '@/db/schema';
import { clndr } from '@/lib/clndr';
 
/** The API's booking row; the SDK's Booking type leaves some of these out. */
export type ApiBooking = {
  id: string;
  booking_page_id: string;
  guest_name: string;
  guest_email: string;
  start_time: string;
  end_time: string;
  status: 'pending' | 'confirmed' | 'cancelled' | 'completed';
  google_meet_link: string | null;
  cancellation_reason: string | null;
  updated_at: string;
};
 
const STATUSES = ['pending', 'confirmed', 'cancelled'] as const;
 
/** Your side effects: post to Slack, update the CRM, email the team. */
async function onBookingChange(booking: ApiBooking, previousStatus: string | null) {
  if (previousStatus === null) console.log('new booking', booking.id, booking.status);
  else if (previousStatus !== booking.status) console.log('status', booking.id, previousStatus, '→', booking.status);
}
 
export async function upsertBookings(bookings: ApiBooking[]) {
  if (bookings.length === 0) return 0;
 
  const before = new Map(
    (
      await db
        .select({ id: clndrBookings.id, status: clndrBookings.status, updatedAt: clndrBookings.clndrUpdatedAt })
        .from(clndrBookings)
        .where(inArray(clndrBookings.id, bookings.map((b) => b.id)))
    ).map((r) => [r.id, r]),
  );
 
  const changed = bookings.filter((b) => {
    const prev = before.get(b.id);
    return !prev || prev.updatedAt.getTime() < Date.parse(b.updated_at);
  });
  if (changed.length === 0) return 0;
 
  await db
    .insert(clndrBookings)
    .values(
      changed.map((b) => ({
        id: b.id,
        bookingPageId: b.booking_page_id,
        guestName: b.guest_name,
        guestEmail: b.guest_email,
        startTime: new Date(b.start_time),
        endTime: new Date(b.end_time),
        status: b.status,
        meetLink: b.google_meet_link,
        cancellationReason: b.cancellation_reason,
        clndrUpdatedAt: new Date(b.updated_at),
      })),
    )
    .onConflictDoUpdate({
      target: clndrBookings.id,
      set: {
        status: sql`excluded.status`,
        startTime: sql`excluded.start_time`,
        endTime: sql`excluded.end_time`,
        meetLink: sql`excluded.meet_link`,
        cancellationReason: sql`excluded.cancellation_reason`,
        clndrUpdatedAt: sql`excluded.clndr_updated_at`,
        syncedAt: sql`now()`,
      },
    });
 
  for (const b of changed) await onBookingChange(b, before.get(b.id)?.status ?? null);
  return changed.length;
}
 
export async function syncBookings() {
  const lists = await Promise.all(STATUSES.map((status) => clndr.bookings.list({ status, limit: 200 })));
  const fetched = lists.flat() as unknown as ApiBooking[];
 
  // Each list holds the 200 latest start times, so an upcoming booking can drop
  // out of view in a busy calendar. Look up any upcoming booking we hold as
  // pending/confirmed that didn't show up under that status this time.
  const seen = new Set(fetched.map((b) => b.id));
  const ours = await db
    .select({ id: clndrBookings.id })
    .from(clndrBookings)
    .where(and(inArray(clndrBookings.status, ['pending', 'confirmed']), gt(clndrBookings.startTime, new Date())));
 
  for (const { id } of ours.filter((r) => !seen.has(r.id)).slice(0, 50)) {
    try {
      fetched.push((await clndr.bookings.get(id)) as unknown as ApiBooking);
    } catch (err) {
      if (!(err instanceof ClndrError && err.status === 404)) throw err;
    }
  }
 
  return { fetched: fetched.length, changed: await upsertBookings(fetched) };
}

Run it every ten minutes

A route handler for the cron, refusing anyone without the secret:

app/api/cron/clndr-sync/route.ts
import { syncBookings } from '@/lib/clndr-sync';
 
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
 
export async function GET(request: Request) {
  const secret = process.env.CRON_SECRET;
  if (!secret || request.headers.get('authorization') !== `Bearer ${secret}`) {
    return new Response('Unauthorized', { status: 401 });
  }
  return Response.json(await syncBookings());
}
vercel.json
{
  "crons": [{ "path": "/api/cron/clndr-sync", "schedule": "*/10 * * * *" }]
}

Set CRON_SECRET in your Vercel project's environment variables and Vercel sends it as the bearer token on every cron call. Vercel's Hobby plan only runs crons once a day; on Hobby, or off Vercel, call the same URL from any scheduler (a GitHub Actions schedule, a system crontab) with Authorization: Bearer $CRON_SECRET.

The request budget

One run costs 3 list calls plus at most 50 lookups. At six runs an hour that's about 18 requests an hour in a quiet month, against a secret key limit of 300 a minute. You can poll every minute if you need to; the rest of your server-side traffic shares the same limit.

What polling can't see

Each status list is capped at 200 bookings, sorted by start time, latest first, with no cursor. Past 200 upcoming bookings in one status, the nearest ones fall below the cut, which is what the lookup step patches, 50 at a time. At that volume, lean on the instant path below and treat the cron as a backstop. A booking cancelled long after its start time can also fall out of the cancelled list. Your copy then keeps its last known status, which for a past meeting is usually what you want anyway.

Store bookings the moment they happen

When the booking happens on your own site, don't wait for the cron. The form hands you the booking id in the browser; post it to a route that fetches the booking with the secret key and upserts it. Fetching by id, instead of trusting the browser's payload, means nobody can write made-up bookings into your table: an id from another account just 404s.

app/api/clndr/booked/route.ts
import { ClndrError } from '@clndr-pro/sdk';
import { clndr } from '@/lib/clndr';
import { upsertBookings, type ApiBooking } from '@/lib/clndr-sync';
 
export async function POST(request: Request) {
  const { bookingId } = (await request.json().catch(() => ({}))) as { bookingId?: unknown };
  if (typeof bookingId !== 'string') return new Response('bookingId required', { status: 400 });
 
  try {
    const booking = await clndr.bookings.get(bookingId);
    await upsertBookings([booking as unknown as ApiBooking]);
  } catch (err) {
    if (err instanceof ClndrError && err.status === 404) return new Response('Unknown booking', { status: 404 });
    throw err;
  }
  return new Response(null, { status: 204 });
}

Then call it from whichever form you use:

app/book/booking-widget.tsx
'use client';
 
import { BookingInline } from '@clndr-pro/react';
 
export function BookingWidget() {
  return (
    <BookingInline
      slug="intro-call"
      onBooked={(bookingId) => {
        void fetch('/api/clndr/booked', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ bookingId }),
        });
      }}
    />
  );
}

If the guest closes the tab before that request goes out, the cron picks the booking up on its next run.

Without Drizzle

The same upsert in SQL, guarded so an older copy never overwrites a newer one:

create table if not exists clndr_bookings (
  id                  uuid primary key,
  booking_page_id     uuid not null,
  guest_name          text not null,
  guest_email         text not null,
  start_time          timestamptz not null,
  end_time            timestamptz not null,
  status              text not null,
  meet_link           text,
  cancellation_reason text,
  clndr_updated_at    timestamptz not null,
  synced_at           timestamptz not null default now()
);
 
insert into clndr_bookings
  (id, booking_page_id, guest_name, guest_email, start_time, end_time, status, meet_link, cancellation_reason, clndr_updated_at)
values
  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (id) do update set
  status              = excluded.status,
  start_time          = excluded.start_time,
  end_time            = excluded.end_time,
  meet_link           = excluded.meet_link,
  cancellation_reason = excluded.cancellation_reason,
  clndr_updated_at    = excluded.clndr_updated_at,
  synced_at           = now()
where clndr_bookings.clndr_updated_at < excluded.clndr_updated_at;

Sync clndr.pro bookings into my databasePaste into Claude Code

Keep a copy of my clndr.pro bookings in this project's database: a polling cron plus an instant update when a booking is made on our site.
 
Read first: https://docs.clndr.pro/tutorials/sync-bookings.md and https://docs.clndr.pro/types.md
 
Facts:
- No webhooks. Poll GET /bookings with the SECRET key (server only): @clndr-pro/sdk 0.1.5+, clndr.bookings.list({ status, limit: 200 }) per status (pending, confirmed, cancelled). Max 200, no cursor, sorted by start_time latest first.
- Booking rows are snake_case and include updated_at (changes on every status change) and cancellation_reason; the SDK's Booking type doesn't declare those, so use a local type.
- clndr.bookings.get(id) for single bookings; an id from another account is a 404 (ClndrError status 404).
- Secret key limit: 300 requests per minute.
- The booking form gives the id in the browser: BookingInline onBooked(bookingId), or the embed's onBookingSuccessful({ meetingId }).
 
Steps:
1. Find this project's database and ORM and its scheduler (Vercel Cron? something else?). Tell me what you'll use.
2. Add a clndr_bookings table (migration in this project's style) keyed by clndr.pro's booking id, with clndr_updated_at.
3. Write upsertBookings (skip rows whose updated_at hasn't moved, call an onBookingChange hook) and syncBookings (three status lists + look up upcoming pending/confirmed rows that didn't appear, max 50 per run).
4. Add a cron route protected by a CRON_SECRET bearer token and schedule it every 10 minutes, and a POST route that takes { bookingId }, fetches it with the secret key and upserts it. Wire our booking form's success callback to that route.
5. Add the env vars to .env.example. Run the type checker and build; fix what breaks.
 
Done when: running the sync twice in a row changes nothing the second time, the cron route rejects requests without the secret, and the secret key is never imported into client code.