Download OpenAPI specification:
One API for Google Calendar and Outlook.
Relay connects your users' Google and Microsoft (Outlook / Microsoft 365) accounts once, keeps their OAuth tokens encrypted and fresh, and serves their calendar events in one normalized shape.
Authentication. Every /v1 route except the OAuth callback and the
provider webhook endpoints requires Authorization: Bearer <api key>.
Keys look like rly_live_… or rly_test_… and are issued with
relayctl tenant create. Any key problem answers the same
401 {"error":"unauthorized"}. Webhooks are public; each subscription
has its own secret (Google channel token, Graph clientState), checked
in constant time. A wrong secret is 401 and is not enqueued.
Tenancy. Everything is scoped to the tenant that owns the key. An
account of another tenant is indistinguishable from one that does not
exist (404 not_found).
Errors. Error bodies are {"error": "<code>"}; query validation errors
add a human-readable detail. Every response carries X-Request-ID.
Link a Google or Microsoft account to the calling tenant (OAuth 2.0 authorization code + PKCE).
Creates a one-time state (valid 10 minutes) and a PKCE verifier,
then sends the user to the provider's consent screen. Browsers cannot
send your API key, so call this from your backend:
302 with Location set to the consent URL;Accept: application/json: 200 with the URL in the body, to
hand to your user's browser.Reading is always included. features=calendar.write also asks for
the scope that writes events. Connecting the same account again with
it upgrades the account in place.
| provider required | string (Provider) Enum: "google" "microsoft" |
| features | string Value: "calendar.write" Comma-separated optional capabilities. |
{
}Public: the browser returns here from the consent screen without an API
key. Security comes from the one-time state. Relay exchanges the
code (with the PKCE verifier), checks the calendar scope, identifies
the account, encrypts the tokens and stores the account. Reloading
the same URL answers 400 invalid_state.
| provider required | string (Provider) Enum: "google" "microsoft" |
| state required | string |
| code | string Present when the user accepted. |
| error | string Present when the user declined (e.g. |
{- "status": "connected",
- "provider": "google",
- "account_id": "449e7a5c-69d3-4b8a-aaaf-5c9b713ebc65",
- "email": "user@example.com"
}Never includes tokens. Ordered by connection time; pass next_page_token back as page_token until it is absent.
| max_results | integer [ 1 .. 100 ] Default: 50 |
| page_token | string Opaque |
{- "accounts": [
- {
- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "provider": "google",
- "email": "user@example.com",
- "status": "active",
- "connected_at": "2019-08-24T14:15:22Z"
}
], - "next_page_token": "string"
}Deletes the account's tokens and marks it disconnected. Google tokens
are also revoked at Google (best effort). Idempotent; connecting the
same provider account again reactivates it.
| id required | string <uuid> The |
{- "error": "unauthorized"
}Google and Outlook events in one shape. Recurring events are expanded; cancelled occurrences are dropped. Access tokens are refreshed silently when they expire.
Paging: follow next_page_token until it is absent. A page may hold
fewer than max_results events, but never zero unless it is the last.
page_token carries the first page's window and size, so other query
parameters are ignored with it.
| id required | string <uuid> The |
| time_min | string <date-time> Start of the window (RFC 3339). Defaults to now. |
| time_max | string <date-time> End of the window (RFC 3339). Defaults to |
| max_results | integer [ 1 .. 250 ] Default: 50 |
| page_token | string <= 4096 characters Opaque |
{- "events": [
- {
- "id": "string",
- "title": "string",
- "start": "2026-10-20T14:00:00Z",
- "end": "string",
- "timezone": "America/Bogota",
- "all_day": true,
- "location": "string",
- "organizer": {
- "email": "string",
- "name": "string"
}, - "provider": "google",
- "version": "string"
}
], - "next_page_token": "string"
}The same body for both providers. Relay translates the time zone and
the recurrence, and refuses what it cannot translate exactly. Needs an
account connected with features=calendar.write.
Attendees receive real invitations from the provider.
| id required | string <uuid> The |
| Idempotency-Key | string <= 255 characters Retrying with the same key and body returns the stored answer for 24 hours instead of writing again. |
| title | string |
| location | string |
| description | string Plain text. |
| start required | string RFC 3339 with an offset, or |
| end required | string Same format as |
| timezone | string IANA zone. Required for timed events. |
| all_day | boolean Default: false |
| attendees | Array of strings <email> [ items <email > ] Bare email addresses. They receive real invitations. |
object (Recurrence) The subset of RRULE both providers represent identically. |
{- "title": "string",
- "location": "string",
- "description": "string",
- "start": "2026-11-02T09:00:00-05:00",
- "end": "string",
- "timezone": "America/Bogota",
- "all_day": false,
- "attendees": [
- "user@example.com"
], - "recurrence": {
- "freq": "DAILY",
- "interval": 1,
- "by_day": [
- "MO"
], - "until": "2019-08-24",
- "count": 1
}
}{- "id": "string",
- "title": "string",
- "start": "2026-10-20T14:00:00Z",
- "end": "string",
- "timezone": "America/Bogota",
- "all_day": true,
- "location": "string",
- "organizer": {
- "email": "string",
- "name": "string"
}, - "provider": "google",
- "version": "string"
}Only the fields sent change. start and end travel together. A
single occurrence cannot be edited; use the series id returned on create.
| id required | string <uuid> The |
| event_id required | string The event id. Use the series id returned on create for a recurring event. |
| Idempotency-Key | string <= 255 characters Retrying with the same key and body returns the stored answer for 24 hours instead of writing again. |
| If-Match | string The event's |
| title | string |
| location | string |
| description | string |
| start | string |
| end | string |
| timezone | string |
| all_day | boolean |
| attendees | Array of strings <email> [ items <email > ] Replaces the whole list. |
object (Recurrence) The subset of RRULE both providers represent identically. |
{- "title": "string",
- "location": "string",
- "description": "string",
- "start": "string",
- "end": "string",
- "timezone": "string",
- "all_day": true,
- "attendees": [
- "user@example.com"
], - "recurrence": {
- "freq": "DAILY",
- "interval": 1,
- "by_day": [
- "MO"
], - "until": "2019-08-24",
- "count": 1
}
}{- "id": "string",
- "title": "string",
- "start": "2026-10-20T14:00:00Z",
- "end": "string",
- "timezone": "America/Bogota",
- "all_day": true,
- "location": "string",
- "organizer": {
- "email": "string",
- "name": "string"
}, - "provider": "google",
- "version": "string"
}Attendees are notified by the provider. A single occurrence cannot be deleted.
| id required | string <uuid> The |
| event_id required | string The event id. Use the series id returned on create for a recurring event. |
| Idempotency-Key | string <= 255 characters Retrying with the same key and body returns the stored answer for 24 hours instead of writing again. |
{- "error": "unsupported_recurrence",
- "detail": "until and count cannot be combined"
}Public push endpoints. Google and Microsoft call these when a calendar
changes. Relay verifies the per-subscription secret, enqueues a sync
job and responds without calling the provider. Absent when
WEBHOOK_BASE_URL is unset.
Public. Google sends X-Goog-Channel-Token, which Relay compares to
the stored hash in constant time. X-Goog-Resource-State: sync is the
initial confirmation and is ignored (200, nothing queued). Any other
state enqueues relay.sync.account and answers 202. An unknown
channel is 200 and is not queued. Relay does not call Google in this
request.
| X-Goog-Channel-ID required | string |
| X-Goog-Channel-Token required | string |
| X-Goog-Resource-State required | string Enum: "sync" "exists" "not_exists" |
| X-Goog-Message-Number | string |
{- "error": "string"
}Public. While a subscription is being created, Graph calls this URL
with validationToken. Relay answers 200 text/plain with that
token, decoded, and nothing else. Otherwise each item's clientState
is compared to the stored hash in constant time. One sync job is
enqueued per account (202). A bad clientState is 401 and
nothing is queued. Relay does not call Graph in this request.
| validationToken | string Present only for the subscription handshake. |
Array of objects |
{- "value": [
- {
- "subscriptionId": "string",
- "clientState": "string",
- "changeType": "string",
- "resource": "string"
}
]
}{- "error": "string"
}Public. reauthorizationRequired enqueues a renew,
subscriptionRemoved enqueues a new subscription, and missed
enqueues a sync. The provider is not called in this request.
The same validationToken handshake as /v1/webhooks/microsoft applies.
| validationToken | string |
{- "error": "string"
}