Calendar
Read calendar event metadata and write events — scoped, revocable, and never the descriptions.
A connection can carry a calendar source alongside mail (Gmail → Google Calendar,
Outlook → Outlook Calendar; IMAP has none). Your app requests calendar.read /
calendar.write, the user approves them on the consent screen, and you read event
metadata and write events through the gateway — under the same fail-closed permission
model as mail.
Metadata, never descriptions
Reads return times, organizer, attendees, and status — never the event description. Webhook payloads are leaner still: organizer and attendee count only.
Request the scopes
Add the calendar scopes to your app's requested scopes in the console (they're distinct
from the calendar email category, which classifies meeting-invite mail):
calendar.read— list calendars, read event metadata, receive calendar webhookscalendar.write— create, update, and delete events
The user approves each individually on the consent screen, and can decline calendar while still granting mail (or vice versa — calendar-only grants work too).
Two things must both be true before events flow: your app holds the grant, and the user
has enabled the calendar source on that connection (a one-time provider re-consent from
their Thread dashboard). GET /connections-level status is surfaced to the user; your app
just sees 403 until both hold.
Read
import { ThreadClient } from '@jointhread/sdk';
const thread = new ThreadClient({
apiKey: process.env.THREAD_API_KEY!,
baseUrl: 'https://gw.jointhread.com',
});
const calendars = await thread.calendars.list({ accountId });
const events = await thread.events.list({
accountId,
timeMin: '2026-06-08T00:00:00Z',
timeMax: '2026-06-15T00:00:00Z',
});
const one = await thread.events.get(events[0]!.id, { accountId, calendarId: events[0]!.calendarId });Write
// Without calendarId, the event lands in a Thread-managed calendar named for your app —
// visible to the user, and removable as a unit. Pass calendarId to target another calendar.
const created = await thread.events.create(
{ accountId },
{ title: 'Kickoff', start: '2026-06-10T15:00:00Z', end: '2026-06-10T15:30:00Z' },
);
await thread.events.update(created.id, { accountId }, { title: 'Kickoff (moved)' });
await thread.events.delete(created.id, { accountId, calendarId: created.calendarId });Receive calendar webhooks
Holding calendar.read also subscribes your webhook to calendar.event.changed —
delivered, signed, and retried exactly like mail events. status: 'cancelled' signals a
deletion. Narrow the union by type:
import { parseWebhook } from '@jointhread/sdk';
const event = parseWebhook({ secret, rawBody, signature, timestamp });
switch (event.type) {
case 'email.classified':
handleEmail(event.data); // fromDomain, subject, labels, …
break;
case 'calendar.event.changed':
handleCalendar(event.data); // eventId, title, start/end, status, attendeeCount, …
break;
}The full payload schema is in Webhooks.
Constraints and errors
The grant's limits (expiry, daily rate limit, schedule) apply to calendar calls exactly as
to mail. Denials return 403 FORBIDDEN — including when the user hasn't enabled the
calendar source yet — and 400 CALENDAR_UNSUPPORTED for IMAP connections. See
Errors & rate limits.
Every endpoint — parameters, responses, try-it console — is in the API reference under the Calendar tag.