Neil Gupta
 

Building an iCal Subscription

I recently wanted to sync events between Guilded and my personal calendar. The simplest solution is to generate an iCalendar file that is served from a personal endpoint for each user. When they subscribe, their calendar app basically acts like an RSS reader and periodically checks the endpoint for the latest list of events.

There are plenty of libraries in your chosen language to generate the .ics file itself, and the spec is simple enough that you could even generate it by hand. I won’t dive into generating the ICS file itself, but here’s a sample with a single event:

BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:Guilded
METHOD:PUBLISH
X-WR-CALNAME:Guilded
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:8fd1dd04-0243-419f-8b0e-186af1375e38-event-209@guilded.gg
SUMMARY:My Fun Event
DTSTAMP:20200903T063216Z
DTSTART:20200826T070000Z
DTEND:20200826T080000Z
DESCRIPTION:yoooo\n\nhttps://neil.gg
URL:https://guilded.gg/teams/...
LOCATION:Monkey Island
STATUS:CONFIRMED
CREATED:20200822T113900Z
END:VEVENT
END:VCALENDAR

In a nutshell, you can add as many VEVENT blocks as you need to show more events. The only catch is that this file needs to show all events that you want in the user’s calendar. Unlike an RSS reader, most calendar apps won’t keep a cache of old events around. That means over time, this file can grow very large. I recommend limiting it to a sensible range (i.e., events from 1 month ago to 1 year from now), depending on your application.

There are also a lot of little things you need to know to actually make this calendar work with the variety of calendar services that I couldn’t find documented anywhere. After a lot of investigating (and trial and error), here’s some tips and tricks to help you build your ICS endpoint faster.

Content Headers

Make sure you’re serving your calendar file with the right headers. In particular, they should look like

Content-Type: text/calendar; charset=utf-8
Content-Disposition: attachment; filename="calendar.ics"

This is pretty straightforward, but easy to miss.

Accept Header

Your server needs to respond to Accept: text/calendar request headers from the client. I discovered the hard way that Node/restify does not by default. It seems iOS is the only one that sends the header, and when the request gets rejected, it shows an unhelpful “Unable to verify account information” error. Since a rejected Accept header returns a 406 Not Acceptable response and I don’t normally log 4xx errors, I wasn’t catching the problem on my end and went down a rabbit hole of trying to debug why iOS hated my server.

URL Length

This one was frustrating to discover, but it seems Google Calendar has a max url length of 256 characters. This isn’t documented anywhere and trying to use a longer URL doesn’t return any error, the UI just silently fails. In my case, the user token was going over that limit, so I had to change my hashing algorithm to create a shorter token.

Query Params

As far as I can tell, query params do work with most calendar apps, but I’d still recommend avoiding them if possible. For example, I changed our calendar URL from https://guilded.gg/users/me/calendar.ics?token=blabla to https://guilded.gg/users/blabla/calendar.ics to ensure that we don’t hit issues with some obscure calendar app. It also helps cut down the url length as a bonus.

Local Testing

It turns out the Apple Calendar on macOS is the most forgiving and works exactly like your browser. It’ll happily sync any valid ICS file without any of the setup above. That means you definitely want to test your endpoint with iOS, Google Calendar, and Outlook directly. I recommend using something like ngrok to quickly test your local endpoint. As a bonus, it includes a detailed dashboard at http://localhost:4040 for viewing request headers and replaying requests to make debugging much easier. This is how I resolved the 406 error above and wish I’d known about the dashboard sooner!

ngrok

Written on September 03, 2020 in Chicago.

Like this post? Follow @neilgupta or subscribe via RSS for more.