← Public packages
@kentcdodds/cal-com
Cal.com helpers for booking pages, event types, slots, and webhooks.
src/cal-com.js
536 lines · 14.8 KB · JavaScriptimport {
bookingscontroller_2026_02_25_cancelbooking,
bookingscontroller_2026_02_25_createbooking,
bookingscontroller_2026_02_25_getbooking,
bookingscontroller_2026_02_25_reschedulebooking,
bookingscontroller_2026_05_01_getbookings,
eventtypescontroller_2024_06_14_geteventtypebyid,
eventtypescontroller_2024_06_14_geteventtypes,
mecontroller_getme,
rawCalRequest,
slotscontroller_2024_09_04_getavailableslots,
webhookscontroller_createwebhook,
webhookscontroller_getwebhooks,
} from './openapi-client.js'
import {
applyEventTypeIdToSlotsQuery,
eventTypeSelectorFromInput,
firstCatalogEventType,
flattenEventTypes,
formatCalErrorDetail,
formatEventTypeCatalog,
hasEventTypeLookupSelector,
isRecord,
isSmokeInput,
normalizeCreateBookingBody,
normalizeCreateWebhookBody,
planAvailableSlotsQuery,
resolveEventTypeFromCatalog,
utcDateOnly,
} from './helpers.js'
const DEFAULT_API_VERSION = '2024-08-13'
const BOOKINGS_LIST_API_VERSION = '2026-05-01'
const BOOKING_API_VERSION = '2026-02-25'
const SLOTS_API_VERSION = '2024-09-04'
const EVENT_TYPES_API_VERSION = '2024-06-14'
const ERROR_DETAIL_MAX = 400
function withoutKeys(record, keys) {
const blocked = new Set(keys)
return Object.fromEntries(
Object.entries(record ?? {})
.filter(([, value]) => value !== undefined)
.filter(([key]) => !blocked.has(key)),
)
}
function queryFromInput(input, ignoredKeys = []) {
if (isRecord(input?.query)) return withoutKeys(input.query, ['smoke'])
return withoutKeys(input, [
'action',
'apiVersion',
'body',
'headers',
'params',
'smoke',
...ignoredKeys,
])
}
function bodyFromInput(input, ignoredKeys = []) {
if (isRecord(input?.body)) return withoutKeys(input.body, ['smoke'])
return withoutKeys(input, [
'action',
'apiVersion',
'query',
'headers',
'params',
'smoke',
...ignoredKeys,
])
}
function bookingUidFromInput(input) {
const value = input?.bookingUid ?? input?.uid ?? input?.params?.bookingUid
if (typeof value !== 'string' || value.trim() === '') {
throw new Error('Cal.com helper requires bookingUid (uid is accepted as an alias).')
}
return value
}
function versionHeaders(apiVersion) {
if (!apiVersion) return {}
return { 'cal-api-version': apiVersion }
}
async function parseBody(response) {
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) return await response.json()
const text = await response.text()
return text ? { text } : null
}
function unwrapData(body) {
if (isRecord(body) && Object.prototype.hasOwnProperty.call(body, 'data')) return body.data
return body
}
async function parseOk(response, pathHint) {
const data = await parseBody(response)
if (!response.ok) {
const detail = formatCalErrorDetail(data)
const suffix = detail ? `: ${detail.slice(0, ERROR_DETAIL_MAX)}` : ''
const error = new Error(
`Cal.com API request failed with ${response.status} ${response.statusText}${suffix}`,
)
error.status = response.status
error.statusText = response.statusText
error.path = pathHint
error.data = data
throw error
}
return data
}
async function callParsed(fn, input, pathHint) {
return unwrapData(await parseOk(await fn(input), pathHint))
}
async function eventTypeCatalogSuffix() {
try {
return ` ${formatEventTypeCatalog(await listEventTypes())}`
} catch {
return ''
}
}
async function withEventTypeCatalogOn404(work) {
try {
return await work()
} catch (error) {
if (error?.status !== 404) throw error
const suffix = await eventTypeCatalogSuffix()
if (suffix) error.message = `${error.message}${suffix}`
throw error
}
}
/**
* Escape-hatch Cal.com API v2 request. Prefer named helpers when available.
* Path is relative to https://api.cal.com (include `/v2/...`) or legacy `/me`-style
* paths which are rewritten to `/v2/...`.
*/
export async function calRequest(input = {}) {
if (!isRecord(input)) throw new Error('Cal.com request input must be an object.')
let path = input.path
if (typeof path !== 'string' || path.trim() === '') {
throw new Error('Cal.com request requires a non-empty path string.')
}
if (/^https?:\/\//i.test(path)) {
throw new Error('Cal.com request path must be relative, for example /v2/me or /me.')
}
path = path.startsWith('/') ? path : `/${path}`
if (!path.startsWith('/v2/') && path !== '/v2') {
path = `/v2${path}`
}
const method = String(input.method ?? (input.body === undefined ? 'GET' : 'POST')).toUpperCase()
const headers = {
...versionHeaders(input.apiVersion ?? DEFAULT_API_VERSION),
...(input.headers ?? {}),
}
const response = await rawCalRequest(path, {
method,
query: input.query,
headers,
body: input.body,
})
const data = await parseOk(response, path)
return { status: response.status, data }
}
export async function getProfile(input = {}) {
return callParsed(
() =>
mecontroller_getme({
headers: {
...versionHeaders(input.apiVersion),
...(input.headers ?? {}),
},
}),
input,
'/v2/me',
)
}
export async function smokeTest() {
const profile = await getProfile()
const summary = {
ok: true,
profile: {
id: profile.id,
username: profile.username,
email: profile.email,
name: profile.name,
timeZone: profile.timeZone,
defaultScheduleId: profile.defaultScheduleId,
},
eventTypeCount: 0,
eventTypeId: null,
checks: {
profile: true,
eventType: 'skipped-no-event-types',
slots: 'skipped-no-event-types',
},
}
const catalog = await listEventTypes()
const picked = firstCatalogEventType(catalog)
summary.eventTypeCount = flattenEventTypes(catalog).length
if (!picked) return summary
const eventType = await getEventType({ id: picked.id })
const start = utcDateOnly(0)
const end = utcDateOnly(7)
const slots = await getAvailableSlots({
eventTypeId: picked.id,
start,
end,
})
summary.eventTypeId = picked.id
summary.eventTypeTitle = eventType?.title ?? eventType?.name ?? picked.title ?? picked.name ?? null
summary.checks.eventType = true
summary.checks.slots = Boolean(slots)
return summary
}
export async function listBookings(input = {}) {
return callParsed(
() =>
bookingscontroller_2026_05_01_getbookings({
query: queryFromInput(input),
headers: {
...versionHeaders(input.apiVersion ?? BOOKINGS_LIST_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
'/v2/bookings',
)
}
export async function getBooking(input = {}) {
const bookingUid = bookingUidFromInput(input)
return callParsed(
() =>
bookingscontroller_2026_02_25_getbooking({
params: { bookingUid },
headers: {
...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
`/v2/bookings/${bookingUid}`,
)
}
export async function createBooking(input = {}) {
const body = normalizeCreateBookingBody(bodyFromInput(input))
return withEventTypeCatalogOn404(() =>
callParsed(
() =>
bookingscontroller_2026_02_25_createbooking({
body,
headers: {
...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
'/v2/bookings',
),
)
}
export async function cancelBooking(input = {}) {
const bookingUid = bookingUidFromInput(input)
return callParsed(
() =>
bookingscontroller_2026_02_25_cancelbooking({
params: { bookingUid },
body: bodyFromInput(input, ['bookingUid', 'uid']),
headers: {
...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
`/v2/bookings/${bookingUid}/cancel`,
)
}
export async function rescheduleBooking(input = {}) {
const bookingUid = bookingUidFromInput(input)
return callParsed(
() =>
bookingscontroller_2026_02_25_reschedulebooking({
params: { bookingUid },
body: bodyFromInput(input, ['bookingUid', 'uid']),
headers: {
...versionHeaders(input.apiVersion ?? BOOKING_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
`/v2/bookings/${bookingUid}/reschedule`,
)
}
export async function listEventTypes(input = {}) {
return callParsed(
() =>
eventtypescontroller_2024_06_14_geteventtypes({
query: queryFromInput(input),
headers: {
...versionHeaders(input.apiVersion ?? EVENT_TYPES_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
'/v2/event-types',
)
}
async function fetchEventTypeById(id, input = {}) {
return withEventTypeCatalogOn404(() =>
callParsed(
() =>
eventtypescontroller_2024_06_14_geteventtypebyid({
params: { eventTypeId: id },
headers: {
...versionHeaders(input.apiVersion ?? EVENT_TYPES_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
`/v2/event-types/${id}`,
),
)
}
export async function getEventType(input = {}) {
if (isSmokeInput(input)) {
const payload = await listEventTypes()
const picked = firstCatalogEventType(payload)
if (!picked) return payload
return fetchEventTypeById(picked.id, input)
}
const selector = eventTypeSelectorFromInput(input)
if (!hasEventTypeLookupSelector(selector)) {
return listEventTypes(input)
}
let id = selector.id
if (id === undefined || id === null || id === '') {
const payload = await listEventTypes()
const resolved = resolveEventTypeFromCatalog(payload, selector)
if (resolved?.id === undefined || resolved?.id === null || resolved?.id === '') {
throw new Error(
`Cal.com helper could not resolve that event type. ${formatEventTypeCatalog(payload)}`,
)
}
id = resolved.id
}
return fetchEventTypeById(id, input)
}
async function fetchAvailableSlots(query, input = {}) {
return withEventTypeCatalogOn404(() =>
callParsed(
() =>
slotscontroller_2024_09_04_getavailableslots({
query,
headers: {
...versionHeaders(input.apiVersion ?? SLOTS_API_VERSION),
...(input.headers ?? {}),
},
}),
input,
'/v2/slots',
),
)
}
/**
* Available slots for an event type over a date range.
* Accepts the same id/slug/title aliases as get-event-type. With no selector,
* returns the event-type list instead of throwing. `{ smoke: true }` fetches
* slots for a real catalog type instead of using a caller placeholder.
*/
export async function getAvailableSlots(input = {}) {
if (isSmokeInput(input)) {
const payload = await listEventTypes()
const picked = firstCatalogEventType(payload)
if (!picked) return payload
const query = applyEventTypeIdToSlotsQuery(queryFromInput(input), picked.id)
if (!query.start) query.start = utcDateOnly(0)
if (!query.end) query.end = utcDateOnly(7)
return fetchAvailableSlots(query, input)
}
const plan = planAvailableSlotsQuery(input)
let query
switch (plan.kind) {
case 'list':
return listEventTypes()
case 'ready':
query = plan.query
break
case 'resolve': {
const payload = await listEventTypes()
const resolved = resolveEventTypeFromCatalog(payload, plan.selector)
if (resolved?.id === undefined || resolved?.id === null || resolved?.id === '') {
throw new Error(
`get-available-slots could not resolve that event type. ${formatEventTypeCatalog(payload)}`,
)
}
query = applyEventTypeIdToSlotsQuery(plan.query, resolved.id)
break
}
default: {
throw new Error(`Unsupported available-slots plan: ${plan.kind}`)
}
}
return fetchAvailableSlots(query, input)
}
export async function listWebhooks(input = {}) {
return callParsed(
() =>
webhookscontroller_getwebhooks({
query: queryFromInput(input),
headers: {
...versionHeaders(input.apiVersion),
...(input.headers ?? {}),
},
}),
input,
'/v2/webhooks',
)
}
export async function createWebhook(input = {}) {
const body = normalizeCreateWebhookBody(bodyFromInput(input))
return callParsed(
() =>
webhookscontroller_createwebhook({
body,
headers: {
...versionHeaders(input.apiVersion),
...(input.headers ?? {}),
},
}),
input,
'/v2/webhooks',
)
}
/**
* Compact booking-page summary for agents: profile + event types with booking URLs.
*/
export async function summarizeBookingPages(input = {}) {
const [profile, eventTypes] = await Promise.all([getProfile(input), listEventTypes(input)])
const username = profile?.username
const types = Array.isArray(eventTypes)
? eventTypes
: (eventTypes?.eventTypeGroups ?? eventTypes?.eventTypes ?? [])
const pages = (Array.isArray(types) ? types : []).map((eventType) => {
const slug = eventType?.slug
return {
id: eventType?.id,
title: eventType?.title ?? eventType?.name,
slug,
lengthInMinutes: eventType?.lengthInMinutes ?? eventType?.length,
hidden: eventType?.hidden,
bookingUrl:
username && slug ? `https://cal.com/${username}/${slug}` : (eventType?.link ?? null),
}
})
return {
username,
name: profile?.name,
timeZone: profile?.timeZone,
eventTypeCount: pages.length,
pages,
}
}
const actions = {
request: calRequest,
'get-profile': getProfile,
profile: getProfile,
'smoke-test': smokeTest,
smoke: smokeTest,
'list-bookings': listBookings,
bookings: listBookings,
'get-booking': getBooking,
'create-booking': createBooking,
'cancel-booking': cancelBooking,
'reschedule-booking': rescheduleBooking,
'list-event-types': listEventTypes,
'event-types': listEventTypes,
'get-event-type': getEventType,
'get-available-slots': getAvailableSlots,
slots: getAvailableSlots,
'list-webhooks': listWebhooks,
webhooks: listWebhooks,
'create-webhook': createWebhook,
'summarize-booking-pages': summarizeBookingPages,
'booking-pages': summarizeBookingPages,
}
/**
* Dispatch Cal.com helper actions such as `list-bookings` or `smoke-test`.
* @param {Object} [input]
* @param {string} [input.action] Action name. Defaults to `smoke-test`.
* @returns {Promise<unknown>} Action-specific Cal.com API payload.
* @example
* import calCom from 'kody:@kentcdodds/cal-com'
* const result = await calCom({ action: 'list-bookings', status: 'upcoming' })
* // => [{ uid: '...', title: '...', ... }]
*/
export default async function calCom(input = {}) {
const action = input.action ?? 'smoke-test'
const handler = actions[action]
if (!handler) {
throw new Error(`Unsupported Cal.com action: ${action}`)
}
return await handler(input)
}