The bentocal package on npm wraps every API endpoint in a typed method. It takes the region from your API key, retries rate limits and provider hiccups for you, throws errors with a stable code, and checks webhook signatures in one call.
- Runs on Node.js 20+, Bun, Deno, Cloudflare Workers and Vercel Edge
- No dependencies: it uses
fetchand Web Crypto - Types generated from the API's OpenAPI document, exported for your own code
- ESM and CommonJS builds
npm install bentocal works.url.Install
npm install bentocalCreate a client
The client reads BENTOCAL_API_KEY from the environment, or takes the key as its first argument. The key already says which region your application lives in, so there's no base URL to set. See Authentication and API keys.
import { BentoCal } from 'bentocal';
const bentocal = new BentoCal(); // reads BENTOCAL_API_KEY
// or: new BentoCal(process.env.MY_BENTOCAL_KEY)With CommonJS:
const { BentoCal } = require('bentocal');Fields use the API's own names (user_id, slot_duration_minutes), so the API reference applies as written. Responses are plain objects with the same keys as the API's JSON. Every request and response type is exported:
import type { ConnectSession, AvailabilityRequest } from 'bentocal';Connect a calendar
Create a connect session for one of your users and send them to its url:
const session = await bentocal.connectSessions.create({
user_id: 'user_123', // your own ID for the user
redirect_uri: 'https://app.example.com/calendar/connected',
});
// Redirect your user to session.urlThey sign in to Google or Microsoft and come back to your redirect_uri with connect_session_id and status in the query string. Read the session to get the connection:
// GET /calendar/connected?connect_session_id=cs_…&status=success
const session = await bentocal.connectSessions.get(connectSessionId);
if (session.status === 'completed') {
console.log('Connected', session.connection!.email);
}To relink a connection whose token stopped working, pass its connection_id when you create the session. See Connecting calendars.
Find a time
start and end take a Date or an ISO 8601 string.
const { slots } = await bentocal.availability.query({
participants: [{ user_id: 'user_123' }, { user_id: 'user_456' }],
periods: [{ start: new Date('2026-11-02T09:00:00+11:00'), end: new Date('2026-11-02T17:00:00+11:00') }],
slot_duration_minutes: 30,
});
// [{ start: '2026-11-01T22:00:00Z', end: '2026-11-01T22:30:00Z', user_ids: ['user_123', 'user_456'] }, …]For raw busy times instead of slots:
const busy = await bentocal.freeBusy.query({
user_ids: ['user_123'],
start: new Date('2026-11-02T00:00:00+11:00'),
end: new Date('2026-11-03T00:00:00+11:00'),
});See Free/busy and availability.
Book it
Events are keyed by your own ID, so the same call creates the event and later updates it, and a retry never makes a duplicate:
await bentocal.events.upsert('user_123', 'booking_789', {
summary: 'Intro call',
start: slots![0]!.start,
end: slots![0]!.end,
time_zone: 'Australia/Sydney',
});
await bentocal.events.delete('user_123', 'booking_789');See Writing events for every field.
Verify webhooks
Pass the raw request body, before any JSON parsing, plus the request headers. The client reads the signing secret from BENTOCAL_WEBHOOK_SECRET, or you can pass secret. verify throws WebhookVerificationError if the signature is missing, wrong or more than five minutes old. During a secret rotation both the old and the new secret are accepted.
// Next.js route handler, Hono, Workers, Bun.serve: anything with a fetch Request
export async function POST(req: Request) {
const event = await bentocal.webhooks.verify({ payload: await req.text(), headers: req.headers });
switch (event.type) {
case 'connection.relink_required':
await askUserToReconnect(event.data.user_id, event.data.connection_id);
break;
case 'calendar.changed':
await resync(event.data.user_id, event.data.calendar_id);
break;
}
return new Response(null, { status: 204 });
}With Express, use express.raw so the body isn't parsed first:
app.post('/webhooks/bentocal', express.raw({ type: 'application/json' }), async (req, res) => {
const event = await bentocal.webhooks.verify({ payload: req.body, headers: req.headers });
// …
res.sendStatus(204);
});Deliveries are retried, so use event.id to skip ones you've already handled. See Webhooks for the event types.
To test a handler locally, sign your own payload and send it in the BentoCal-Signature header:
import { signWebhook } from 'bentocal';
const body = JSON.stringify({ id: 'evt_test', type: 'calendar.changed', created_at: new Date().toISOString(), application_id: 'app_test', data: { /* … */ } });
const signature = await signWebhook({ payload: body, secret: 'whsec_test' });
await fetch('http://localhost:3000/webhooks/bentocal', { method: 'POST', body, headers: { 'BentoCal-Signature': signature } });Errors
API errors throw BentoCalApiError. Branch on its code, which is stable; message is for people.
import { BentoCalApiError } from 'bentocal';
try {
await bentocal.events.upsert(userId, bookingId, event);
} catch (err) {
if (err instanceof BentoCalApiError && err.code === 'relink_required') {
// Send the user through a new connect session with connection_id
}
throw err;
}BentoCalApiError has status, code, message, requestId (the same ID as in the console's logs; quote it to support), details (validation issues) and retryAfter. Network failures and timeouts throw BentoCalConnectionError. Both extend BentoCalError. See Errors for every code.
Retries and timeouts
Network errors, 429 and 502–504 are retried twice with exponential backoff. When the API sends Retry-After, the SDK waits that long, up to 60 seconds; a longer wait throws instead. Each attempt times out after 30 seconds. See Limits and rate limits.
Set them for the client, or for one call as the method's last argument, which also takes an AbortSignal:
const bentocal = new BentoCal({ maxRetries: 4, timeout: 10_000 }); // milliseconds
await bentocal.users.get('user_123', { timeout: 5_000, signal: AbortSignal.timeout(8_000) });Configuration
| Option | Environment variable | Default |
|---|---|---|
apiKey | BENTOCAL_API_KEY | Required |
webhookSecret | BENTOCAL_WEBHOOK_SECRET | None |
baseUrl | BENTOCAL_BASE_URL | https://api.bentocal.com/{region}/v1, region from the key |
timeout | 30000 ms | |
maxRetries | 2 | |
fetch | globalThis.fetch |
With the environment variables set, new BentoCal() needs no arguments.
All methods
| Method | Endpoint |
|---|---|
connectSessions.create(body) | POST /connect_sessions |
connectSessions.get(id) | GET /connect_sessions/{id} |
users.get(userId) | GET /users/{user_id} |
users.update(userId, { default_connection_id }) | PATCH /users/{user_id} |
users.delete(userId) | DELETE /users/{user_id} |
connections.get(id) | GET /connections/{id} |
connections.delete(id) | DELETE /connections/{id} |
connections.listCalendars(id) | GET /connections/{id}/calendars |
freeBusy.query(body) | POST /free_busy |
availability.query(body) | POST /availability |
events.upsert(userId, eventId, body) | PUT /users/{user_id}/events/{event_id} |
events.delete(userId, eventId) | DELETE /users/{user_id}/events/{event_id} |
webhooks.verify({ payload, headers, secret? }) | Checks a webhook locally |
The package is MIT licensed.