Calimatic Connect|Documentation
PricingSign In

Unified Calendar API

One normalized API for calendars and events that works identically across Google Calendar and Microsoft Outlook. Connect handles OAuth, token refresh, host routing, and normalizes both providers — including recurring events — behind a single schema. You write your calendar UI once.

What Connect handles vs. what you do

  • Connect: OAuth consent, token storage/refresh, host routing, provider normalization, recurrence conversion.
  • You: resolve the connection id, call the unified endpoints, render the unified schema. No per-provider mapping.

Supported providers

  • google_calendar — Google Calendar (calendar-only scope)
  • microsoft_calendar — Outlook / Microsoft 365, calendar-only (no mailbox access) — use this for calendar sync
  • microsoft_outlook — Outlook with mail + calendar (only if you also need mail)
  • gmail — an org that connected Google via the Gmail provider can reach its calendar too

For calendar-only consent (instructors/parents aren't prompted for mail), use google_calendar and microsoft_calendar.

1. Connect a calendar (one-time, per workspace)

Each workspace authorizes its calendar through Connect's provider flow — Connect owns the OAuth consent and tokens. You never touch the Google/Microsoft OAuth screens or SDKs.

Popup flow — the single Connect URL (recommended for multi-tenant portals)

Any tenant portal — portal.icodeschool.com, portal.valhallan.com, any domain — opens a popup to the one Connect login URL. No redirect_uri, no per-tenant allow-listing: Connect runs the OAuth and posts the result back to the window that opened it, then closes. This works for unlimited white-label domains with zero Connect config.

// From any tenant portal (any domain): const popup = window.open( `https://connect.calimatic.com/auth/google_calendar/login?accountId=${workspaceId}`, 'connect_oauth', 'width=600,height=700' ); window.addEventListener('message', (e) => { if (e.origin !== 'https://connect.calimatic.com') return; // verify sender! if (e.data?.type === 'oauth_success') { // externalAccountId echoes back the id you passed in (or the account Connect // auto-derived), so you can map the connection to your own user/instructor. const { connectionId, provider, accountId, externalAccountId } = e.data; } });

Because there's no cross-domain redirect (the result returns to the exact window that started it), there's no open-redirect surface and nothing to allow-list. Use microsoft_outlook in the path for Outlook. Always check event.origin before trusting the message.

Per-user connections (one workspace, many accounts)

To keep a separate connection per instructor/user in a single workspace, add external_account_id=<your-user-id> to the login URL. Connect keys a distinct connection to that id, echoes it back as externalAccountId in the oauth_success message, and lets you fetch it later with GET /connect/v1/connections?external_account_id=<your-user-id>. Even without the parameter, Connect now auto-derives a distinct id from the Google/Microsoft account, so a second account no longer overwrites the first — but passing your own id is recommended because it's deterministic and lets you look connections up by your identifier.

Alternative: full-page redirect via a relay

If you need a non-popup (full-page) flow, point all connects at one fixed Calimatic-trusted relay redirect_uri and carry the tenant in external_account_id; Connect redirects back to the relay with connection_id&provider&account_id&external_account_id&status=connected, and your relay dispatches to the tenant server-side. The relay must sit on a trusted domain (*.calimatic.com needs no config; another domain is allow-listed oncevia ALLOWED_REDIRECT_DOMAINS) — never per tenant.

2. Authenticate to Connect (S2S)

Send one of these on every request:

  • Authorization: Bearer <SERVICE_AUTH_SECRET> — shared internal service secret
  • x-api-key: <API_KEY> + X-Account-Id: <workspaceId> — developer key
  • Authorization: Bearer <ck_…>:<cs_…> — developer key:secret

3. Resolve the connection id

GET /connect/v1/connections?provider=google_calendar x-api-key: <API_KEY> X-Account-Id: <workspaceId> → [ { "id": "<connectionId>", "provider": "google_calendar", "status": "connected", ... } ]

Use provider=microsoft_outlook for Outlook. Keep the connectionId.

4. Endpoints

All under /api/unified/calendar/*, passing connectionId and organizationId (the workspace id) as query params. There are /api/unified/user/calendar/* equivalents that take a logged-in user's JWT + connectionId instead of S2S auth.

GET /api/unified/calendar/providers GET /api/unified/calendar/calendars?connectionId&organizationId POST /api/unified/calendar/calendars?connectionId&organizationId // create (Classes/Blackouts) POST /api/unified/calendar/calendars/:calendarId/share?connectionId&organizationId GET /api/unified/calendar/events?connectionId&organizationId&calendarId&start&end&limit&cursor&sync&syncToken GET /api/unified/calendar/events/:eventId?connectionId&organizationId&calendarId POST /api/unified/calendar/events?connectionId&organizationId&calendarId&idempotencyKey POST /api/unified/calendar/events/batch?connectionId&organizationId PATCH /api/unified/calendar/events/:eventId?connectionId&organizationId&calendarId DELETE /api/unified/calendar/events/:eventId?connectionId&organizationId&calendarId

5. Event schema (identical for both providers)

{ "id", "calendarId", "title", "description", "location", "start": { "dateTime": "2026-09-20T10:00:00Z", "date": null, "timeZone": "UTC" }, "end": { ... }, "allDay": false, "status", "organizer": { "email", "name" }, "attendees": [ { "email", "name", "responseStatus": "accepted|declined|tentative|needsAction" } ], "meetingUrl", "webLink", "recurringEventId", "recurrence": { "frequency": "weekly", "interval": 1, "count": 10, "byDay": ["MO","WE"] }, "metadata": { "yourId": "class-42" }, // your own tags (see Two-way sync) "deleted": false, // true = deletion tombstone (incremental sync) "createdAt", "updatedAt", "raw" }

Create / update body (UnifiedEventInput)

{ "title": "Planning", "description": "...", "location": "Room 1", "start": { "dateTime": "2026-09-20T10:00:00Z" }, // or { "date": "2026-09-20" } for all-day "end": { "dateTime": "2026-09-20T11:00:00Z" }, "allDay": false, "timeZone": "UTC", "attendees": [ { "email": "b@x.com", "name": "B" } ], "recurrence": { "frequency": "weekly", "interval": 1, "byDay": ["MO"], "count": 10 }, "metadata": { "yourId": "class-42" } }

6. Behaviors to code against

TopicBehavior
ListingReturns expanded instances in a time window. start/end are ISO-8601; if omitted, defaults to now → +30 days.
PaginationFollow nextCursor until hasMore is false. Connect abstracts Google's pageToken and Outlook's nextLink.
RecurrenceSend the recurrence object on create/update. To read the rule, GET the series master (list items carry recurringEventId). Supports daily/weekly/monthly (by month-day or nth weekday)/yearly with interval, count, until, byDay, byMonthDay, byMonth. Exotic RRULEs fall back to raw.
TimezoneOutlook requires it on writes — if you omit timeZone, Connect defaults to UTC. Always send it.
All-daySend allDay: true with start.date/end.date (no time).
meetingUrlPopulated from Google Meet/conferenceData or Outlook onlineMeeting.joinUrl when present.

Example: list this week's events

GET /api/unified/calendar/events ?connectionId=<id>&organizationId=<workspaceId> &start=2026-09-14T00:00:00Z&end=2026-09-21T00:00:00Z&limit=50 Authorization: Bearer <SERVICE_AUTH_SECRET> → { "data": [ <UnifiedEvent>, ... ], "nextCursor": "...", "hasMore": true }

Two-way sync

For keeping an external system in step with the calendar (e.g. LMS class schedules), combine incremental sync, change notifications, custom IDs, and idempotent batch writes.

Incremental sync + deletions

Instead of re-listing a window, pull only what changed. Start with an initial sync, then replay the token. Deletions come back as tombstones (deleted: true).

# initial sync (optionally bounded by &start=) GET /api/unified/calendar/events?connectionId&organizationId&sync=true → { "data": [ ... ], "hasMore": true|false, "nextSyncToken": "<token>" } # later — only changes since the token (includes deletions) GET /api/unified/calendar/events?connectionId&organizationId&syncToken=<token> → { "data": [ { ..., "deleted": true }, ... ], "nextSyncToken": "<newToken>" }

Paginate with nextCursor until hasMore is false; the final page carries the nextSyncToken to persist for next time. (If a token expires, do a fresh sync=true.)

Change notifications (webhooks)

Subscribe once per connection and Connect creates the upstream Google/Outlook change channel and forwards notifications to your URL. Notifications are a "something changed"signal — on receipt, pull the delta with your syncToken.

POST /connect/v1/webhooks/microsoft_calendar/subscribe (or google_calendar) { "callbackUrl": "https://your.app/webhooks/calendar", "callbackSecret": "...", "connectionId": "<id>", "metadata": { "calendarId": "<id>" } } → { "success": true, "subscriptionId": "...", "upstream": { "created": true } } DELETE /connect/v1/webhooks/:provider/subscribe/:id to stop

connectionId targets a specific connection; metadata.calendarId scopes it to one calendar. Forwarded notifications carry X-Connect-Connection-Id, X-Connect-Calendar-Id, and X-Connect-Provider headers (and X-Connect-Signature when a callbackSecret is set) so you know exactly which connection/calendar — and which sync token — to pull.

Check upstream in the response: { "created": true } means the provider-side change channel is live. { "created": false, "error": "..." } means your callback is registered but the provider will not push changes yet — for Google this usually means the notification domain still needs to be verified in Google Cloud Console. The stored subscription never returns callbackSecret in plaintext (only hasSecret: true).

Your own IDs (sync-loop avoidance)

Send metadata on create/update and read it back on events to recognize records you created and avoid echo loops. Stored as Google extendedProperties.private / Outlook extended properties.

Batch writes + idempotency

Apply many changes in one call. On create, pass an idempotencyKey — a repeat key returns the existing event instead of creating a duplicate (safe to retry).

POST /api/unified/calendar/events/batch?connectionId&organizationId { "operations": [ { "op": "create", "idempotencyKey": "class-42-2026-09-20", "event": { ... } }, { "op": "update", "eventId": "<id>", "event": { ... } }, { "op": "delete", "eventId": "<id>" } ] } → { "results": [ { "op":"create", "status":"created|deduped", "event": {...} }, ... ] }

create and update ops require an explicit calendarId (on the op, or the request-level calendarId). An op without one fails with status: "error" rather than silently writing to the primary calendar. A partial update only sends the fields you include — omit start/end to change just the title, etc. Deleting an already-deleted event is idempotent (returns success, not an error).

Separate calendars (Classes / Blackouts) — create & share

Create a dedicated calendar and grant access to instructors/parents, then target it with calendarId on any read/write/batch call.

POST /api/unified/calendar/calendars { "name": "Classes" } → { "id", "remoteId", "name", "primary", "timeZone", ... } POST /api/unified/calendar/calendars/<calendarId>/share { "email": "teacher@school.com", "role": "writer" } // reader | writer | owner

(Google maps to ACL rules, Outlook to calendar permissions. To detect that an event no longer exists, GET …/events/:id returns 404; an expired sync token returns 410 with code: "SYNC_TOKEN_EXPIRED" → do a full sync=true resync.)

Rate limits

Limits are scoped per tenant (each workspace/API-key gets its own bucket — one tenant can't starve another) with a generous ceiling. Tell us your peak throughput and we'll size it. Calendar calls are not metered against any plan quota.

See also

  • Swagger (includes the unified calendar operations): /api/docs
  • API Proxy (raw provider calls): /docs/api-proxy
  • Connections API: /docs/connect-api