Our Slack bot introduces two people and asks them to have a coffee. The obvious next step is putting that meeting in their calendars, and the obvious way is the Google Calendar API: per-user OAuth, a consent screen, refresh tokens to store and revoke, and the same work again for Microsoft.
We shipped it without any of that: a Google template URL and an .ics download, both built on the server and carried in the message. The person clicking creates the event in their own calendar, so we hold no tokens and ask for no access. Here is what that costs, and the five things that bit us.
Why not just call the API
Reading free and busy times is a second identity system. Our only sign-in is Slack. Calendar access would mean per-user consent for two providers, stored third-party refresh tokens (a materially larger thing to protect than anything else we hold) and a new paragraph in the privacy policy. It also only pays off once both people in a pair have connected. Below that it degrades to a link with a suggested time, which is exactly what we built. So we built that first and kept the option.
1. Leaving the time out does not leave the time blank
Google's template URL takes the event as query parameters:
https://calendar.google.com/calendar/render
?action=TEMPLATE
&text=Coffee chat with Dana
&dates=20260922T130000Z/20260922T133000Z
&details=Paired by CoffeeSlack
&add=dana@example.com
We assumed omitting dates would open the editor with an empty time. Tested against a real calendar, it does not: Google pre-fills the next slot from "now", one hour long. A click at around 21:00 on a Friday was offered a Friday coffee at 21:30. A default you choose beats the one Google chooses, so we always send a slot, which is what dragged time zones into a feature that looked like it could avoid them.
2. A Z timestamp renders in each reader's own zone
We sent dates=20260922T130000Z/20260922T133000Z and opened it in a browser two hours ahead of UTC. It rendered as 15:00 to 15:30, with the 30-minute length intact. That is one URL that is correct for both people in a pair even in different countries. The time zone we look up is only ever used to pick a reasonable hour: an unknown zone degrades the suggestion, it never makes the link wrong.
3. Turning 14:00 local into an instant takes two passes
To offer "2pm in their own time" you need the moment at which the clock in some zone reads 14:00. The tempting version reads the offset once:
const guess = Date.UTC(year, month - 1, day, 14, 0);
return guess - offsetAt(guess, zone); // wrong near a DST change
Treating the wall time as UTC can land the guess on the far side of a daylight-saving change from the real moment, so the offset you read is the other one. Resolving twice fixes it:
const first = guess - offsetAt(guess, zone);
return guess - offsetAt(first, zone);
Searching all 418 zones for a disagreement between the two versions found exactly three: Pacific/Auckland, Pacific/Chatham and Antarctica/McMurdo (which runs on New Zealand time, and which we missed the first time). All at 14:00, and only on the two Saturdays in 2026 when their clocks change. Our slot rule never returns a Saturday, so the bug could not be reached. We kept the second pass anyway, because the defect belongs to the helper, not to the rule that happens to dodge it.
Two smaller things in the same corner. Asking for an unknown zone throws, and the zone name comes from someone's chat profile, so it needs a try and a fallback. And some builds render midnight as hour 24, which quietly shifts a date by a day unless you take it modulo 24.
4. The .ics line limit is 75 octets, not 75 characters
RFC 5545 says a line should not exceed 75 octets, and a continuation begins with a single space or tab. Counting characters instead of bytes works on every name you will test with and breaks on the first one written in Cyrillic or containing an emoji, which arrives cut in half. Walk back off the UTF-8 continuation bytes before you split:
while (end > start && end < bytes.length
&& (bytes[end] & 0xc0) === 0x80) end -= 1;
The leading space on a continuation costs an octet too, so every chunk after the first gets 74.
Escaping has an order: the backslash goes first, or you escape the backslashes you just introduced. Then semicolon, comma, and newline to a literal backslash-n. And the trap we actually shipped into a branch: in JavaScript '\;' is not an escaped semicolon, it is a semicolon. The line that looked like it escaped them compiled, ran and did nothing, so a name like Smith; Jane would have broken the property in two. A linter caught it. The test we had written did not, because it asserted against a string built with the same mistake. Two wrongs agreeing is the normal way this survives review. The fix was to assert by character code instead of writing the expected value as a literal.
Finish with CRLF between every line and one at the end. Outlook is the client that minds.
5. Where you put the token decides who logs it
A calendar client fetches the file with no headers we control, so the URL itself has to be the credential. The first version put the token in the path:
/calendar/coffee.ics/<token> first version
/calendar/coffee.ics?token=... second version
We moved it because two pieces of our own code already had an opinion. Our request log records the path and drops the query, under a comment saying the query string can carry secrets. Our error reporter redacts query parameters named token, and only query parameters. A token in the path would have been written to our log on every fetch and shipped to the error reporter on every 5xx. A reviewer found it; the author did not.
And that was still not enough. Our platform writes its own request log. The application never sees it and cannot configure it, and on Cloud Run the field it records is the full URL including the query string. So the token we had just moved into the query was sitting in that log on every fetch: 104 of them, in clear text, behind a fix that had been reviewed and merged as complete. Nothing leaked. Every one of those fetches was our own probing, all in the hour after the deploy and before the first real link went out. We know that from the timestamps rather than the status codes, because the rate limiter answers before the token is read, so a 429 never tells you whether the token was valid.
The repair is a logging exclusion, because the platform can drop a matching entry but cannot redact a field inside one. Two things worth carrying:
- Anchor the match. Our first filter matched the path as a substring, so anyone could suppress their own log entry by hanging that string off the end of any other URL: a tool for hiding from the logs, shipped as a security fix. Anchor on host and path, and test that a crafted URL fails to match.
- Know what the exclusion costs. The platform log was the only copy of the client address, user agent and response size. On a public route those are exactly the fields you want when something goes wrong. If it starts to matter, log them yourself from inside the route, where you can leave the secret out on purpose.
The general shape is the part worth keeping: your logging and error reporting encode a belief about which half of a URL is secret, and it is usually right. Your platform holds its own belief, it is usually simpler, and it has not read your comments.
What we deliberately left out
- No ATTENDEE, ORGANIZER or METHOD in the .ics. Depending on the client, ATTENDEE reads as an invitation or as an ordinary appointment, and it puts a colleague's address in a file in a Downloads folder. Inviting the other person is the Google link's job, where the person clicking is the one inviting.
- No signature on the token. The identifier carries 48 bits from a random-bytes call, so forging one means guessing those bits. An HMAC adds a secret to rotate and forget (ours sat unset for 26 days once) while protecting nothing: the only person who benefits from forging a pair member's copy is the pair's other member, who was sent the same details anyway.
- No availability lookup. That needs the API, which needs the OAuth, which is the project we did not do. The slot we suggest is a guess both people are expected to move.
When this is the wrong approach
Everything above depends on a person being there to click. If you need to know when someone is free, or to put an event in their calendar while they are asleep, none of this helps and you are back to the API and the consent screen. What it buys is the 80 percent case at none of the cost, and the option to do the rest later.
The Google behaviour described here was checked against a live calendar on 21 September 2026. It is not documented anywhere we could find, so it can change without warning. Check it yourself before relying on it.
This is the bot it went into: CoffeeSlack pairs teammates for a coffee chat in Slack. Free for every team, no card.
Originally published at coffeeslack.com.
Top comments (0)