POST /api/external/calendars/ical
Proxy an external iCal feed (Preview modal)

Request headers

Only a registered user can perform this action
  Authentication - string - required
    Example of Authentication header : "Bearer TOKEN_FETCHED_FROM_SERVER_DURING_REGISTRATION"

Request body example

{
  "url": "https://calendar.example.com/team/games.ics"
}

Success response (200)

Content-Type: text/calendar
The RAW feed body is returned byte-for-byte exactly as served by the
upstream host — nothing is parsed, selected, dropped or renamed. Every
VEVENT (with all properties), VTIMEZONE and calendar headers are passed
through untouched; the frontend parses SUMMARY/DTSTART/UID/all-day/
timezones. A valid feed with no events (empty VCALENDAR) is a success and
is returned as-is.

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Gipper//Autocreate//EN
BEGIN:VEVENT
UID:event-001@gipper.test
SUMMARY:Varsity Football vs Central
DTSTART:20260910T190000Z
DTEND:20260910T210000Z
LOCATION:Home Stadium
END:VEVENT
END:VCALENDAR

Delivery and caching

A feed of up to 2 MB is returned in one response and cached for 15 minutes;
`refresh` bypasses that cache and repopulates it. A larger feed is streamed
(chunked, Cache-Control: no-transform, no Content-Length) and never cached.
A transfer that breaks mid-stream is aborted: the response is left
UNTERMINATED (no final zero-length chunk) rather than closed cleanly, so
the client raises a network error instead of accepting a silently
truncated calendar as a complete one. Treat a 200 whose body did not
finish arriving as a failed request, not as a short calendar.

Interim response (202) — single-flight herd protection

Concurrent requests for one feed are coalesced: exactly ONE goes to the
provider while the others are answered from the cache. A caller that has no
cached body to serve AND is not the one refreshing receives a 202 telling it
to poll again — the frontend retries after `retry_after` seconds and gets the
200 once the leader has warmed the cache. A stale cached body is ALWAYS
preferred over a 202 (data beats "please wait").

HTTP/1.1 202 Accepted
Content-Type: application/json
Retry-After: 2
Cache-Control: no-store
{
  "status": "updating",
  "message": "The calendar is updating",
  "retry_after": 2,
  "stage": "fetching"
}

`retry_after` is set by the server and duplicated in the body (a cross-domain
client cannot read the header without Access-Control-Expose-Headers): 2s
while the leader is fetching, 10s (`"stage": "streaming"`) once it has crossed
the 2 MB streaming boundary. This interim answer is behind ICAL_WAIT_202_ENABLED
(off by default); with it off a caller in this position simply fetches instead.

Error response body

The upstream status is passed through when the provider ANSWERED but with a
non-200 (404 the feed is gone, 429 the provider is throttling us, 5xx the
provider is down). When nothing usable answered at all — SSRF-blocked host,
DNS failure, timeout, malformed URL — the status is 422. The body is always
ours; an upstream error page is never proxied back.
{
  "message": "The iCal URL you entered is not valid"
}

Error response body (403) — beta gate (GP-16587, temporary)

While CALENDARS_BETA_ENABLED=true only users granted `calendars_enabled`
may call this endpoint; open to everyone once the variable is off.
{
  "message": "This feature is not available for your account yet.",
  "code": "feature_not_available"
}

Params

Param name Description
url
required

iCal feed URL (webcal:// or https://)

Validations:

  • Must be a String

refresh
optional

Bypass the 15-minute cache and re-fetch from the provider (Refresh button)

Validations:

  • Must be one of: true, false, 1, 0, true, false.


GET /api/external/calendars/:id/ical
Re-fetch a saved calendar feed (Events tab / Refresh)

Request headers

Only a registered user can perform this action
  Authentication - string - required
    Example of Authentication header : "Bearer TOKEN_FETCHED_FROM_SERVER_DURING_REGISTRATION"

Access

A workspace calendar is reachable by any workspace user (incl. non-admins);
another user's personal calendar returns 404. last_synced_at is updated on
a successful NETWORK fetch only — a cache hit is not a sync, so it leaves
the timestamp alone rather than reporting a feed as fresher than it is.

Success response (200)

Content-Type: text/calendar
Same raw-body proxy, caching and streaming behaviour as
POST /api/external/calendars/ical (the saved calendar's stored URL is
fetched); the two share one cache entry per feed URL. Nothing is parsed.

Error response body

Same split as POST /api/external/calendars/ical: an upstream non-200 is
passed through, anything that never answered is a 422. A provider 404 still
carries the message below, so it cannot be confused with the 404 this
endpoint returns for a calendar the user may not access.
{
  "message": "The iCal URL you entered is not valid"
}

Error response body (403) — beta gate (GP-16587, temporary)

While CALENDARS_BETA_ENABLED=true only users granted `calendars_enabled`
may call this endpoint; open to everyone once the variable is off.
{
  "message": "This feature is not available for your account yet.",
  "code": "feature_not_available"
}

Params

Param name Description
id
required

ID of a calendar accessible to the current user

Validations:

  • Must be a Integer

refresh
optional

Bypass the 15-minute cache and re-fetch from the provider (Refresh button)

Validations:

  • Must be one of: true, false, 1, 0, true, false.