NORMAL
← cd ~/blog
deep-dive #ical#rfc-5545#calendar#api#auth

The four traps in hand-rolling an .ics calendar feed

Emitting an RFC-5545 feed is mostly string-building, but four non-obvious rules bite you: all-day DTEND is exclusive, lines fold at 75 octets, TEXT values need escaping, and the clients that consume the feed can't send an auth header.

I wanted due-dated tickets to show up in a calendar app. The format — text/calendar, RFC 5545 — is just a text document you generate on the fly, no library required. But “just build a string” hides four rules that each fail quietly, and one of them isn’t about the format at all.

1. All-day DTEND is exclusive

An all-day event uses DATE values, not DATE-TIME:

DTSTART;VALUE=DATE:20260815
DTEND;VALUE=DATE:20260816

The instinct is to set DTEND equal to DTSTART for a one-day event. That renders as a zero-length event, and some clients drop it entirely. The all-day end is exclusive — it’s the first day the event no longer covers. A single day due on the 15th ends on the 16th. Off-by-one, but the compiler can’t help you; only a calendar app will.

2. Lines fold at 75 octets

Content lines longer than 75 octets (not characters — UTF-8 bytes) must be wrapped: break the line, emit CRLF, and start the continuation with a single leading space. Miss it and long summaries get truncated or rejected by stricter parsers. The octet-vs-character distinction matters the moment a title has an emoji or an accented letter, so fold by byte length, not string length.

3. TEXT values need escaping

In SUMMARY, DESCRIPTION, etc., four characters are special: backslash, semicolon, comma, and the newline. They escape to \\, \;, \,, and a literal \n. Forget this and a title with a comma silently splits the field, or a stray semicolon corrupts everything after it. Order matters — escape the backslash first, or you double-escape the others.

4. The consumer can’t send an auth header

This is the one that isn’t in the spec. A calendar subscription is a URL the app polls on its own schedule — there is no place to attach an Authorization header. So a feed behind bearer-token auth is unsubscribable by design.

The fix is to accept the token as a query parameter as well as a header — the calendar app uses ?token=…, and your own UI can still use the header via its proxy:

GET /calendar.ics?token=<token>   →  validate query param OR bearer, then serve

It’s worth being deliberate here: a token in a URL lands in logs and browser history, so it should be a purpose-scoped read-only token, not your primary credential. But the general lesson generalises past calendars — any feed consumed by a dumb client (RSS, iCal, a webhook target) has to carry its auth in the URL, because there’s nothing on the other end to set a header.

None of these four are hard. They’re just invisible until something downstream refuses to render, and by then the failure is three layers away from the line that caused it.