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 syncmicrosoft_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.
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 secretx-api-key: <API_KEY>+X-Account-Id: <workspaceId>— developer keyAuthorization: Bearer <ck_…>:<cs_…>— developer key:secret
3. Resolve the connection id
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.
5. Event schema (identical for both providers)
Create / update body (UnifiedEventInput)
6. Behaviors to code against
| Topic | Behavior |
|---|---|
| Listing | Returns expanded instances in a time window. start/end are ISO-8601; if omitted, defaults to now → +30 days. |
| Pagination | Follow nextCursor until hasMore is false. Connect abstracts Google's pageToken and Outlook's nextLink. |
| Recurrence | Send 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. |
| Timezone | Outlook requires it on writes — if you omit timeZone, Connect defaults to UTC. Always send it. |
| All-day | Send allDay: true with start.date/end.date (no time). |
| meetingUrl | Populated from Google Meet/conferenceData or Outlook onlineMeeting.joinUrl when present. |
Example: list this week's events
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).
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.
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).
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.
(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