Thread Docs

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 webhooks
  • calendar.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.