Calendar invites without OAuth
Our Slack bot introduces 2 people and asks them to have a coffee. The obvious next step is putting that meeting in their calendars, and the obvious way to do it is the Google Calendar API — which means 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.icsdownload, 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. What follows is what that costs, and the 5 things that bit us on the way.
The trade
Why not just call the API
Reading free and busy times is a second identity system. Today our only sign-in is Slack. Adding calendar access means per-user consent for 2 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 of 5
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.comWe assumed that omitting dates would open the editor with the time empty and let people choose. Tested against a real calendar, it does not. Google pre-fills the next slot from “now”, 1 hour long. A click at around 21:00 on a Friday was offered a Friday coffee at 21:30.
A default you choose is better than 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 of 5
A Z timestamp renders in each reader’s own zone
We sent dates=20260922T130000Z/20260922T133000Z and opened it in a browser 2 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 when they are in different countries, and it is why 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 of 5
Turning 14:00 local into an instant takes 2 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 changeTreating the wall time as though it were UTC can land the guess on the far side of a daylight-saving change from the real moment, and the offset you then read is the other one. Resolving twice fixes it: use the first answer to ask for the offset again.
const first = guess - offsetAt(guess, zone);
return guess - offsetAt(first, zone);Searching all 418 zones for a disagreement between the 2 versions found exactly 3: Pacific/Auckland, Pacific/Chatham, and Antarctica/McMurdo, which runs on New Zealand time and which we missed the first time we wrote this down. All at 14:00, and only on the 2 Saturdays in 2026 when their clocks change. Our slot rule never returns a Saturday, so the bug could not actually be reached — we kept the second pass anyway, because the defect belongs to the helper and 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 rather than from us, 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 of 5
The .ics line limit is 75 octets, not 75 characters
RFC 5545 says a line should not exceed 75 octets, and that a continuation begins with a single space or tab — we use a space. It is a recommendation rather than a prohibition, but clients hold you to it. 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 rather than 75.
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. Which brings the trap we actually shipped into a branch:
In JavaScript '\;' is not an escaped semicolon. It is a semicolon. So the line that looked like it escaped them compiled, ran, and did nothing, and 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 stop writing the expected value as a literal and assert by character code instead.
Finish with CRLF between every line and one at the end. Outlook is the client that minds.
5 of 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. Ours names the pair and the person, and the first version put it in the path:
/calendar/coffee.ics/<token> first version
/calendar/coffee.ics?token=... second versionThe reason for the move was 2 pieces of our own code that 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 a few others — query parameters only. A token in the path would have been written to our log on every fetch and shipped to the error reporter on every 5xx. Naming the parameter token so the existing redaction matched was a 2-line change a reviewer found and the author did not.
And that was still not enough
Our platform writes its own request log. The application never sees it, 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 all before the first real link went out. We know that from the timestamps rather than from the status codes — 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 about that are worth carrying:
- Anchor the match. Our first filter matched the path as a substring, which meant 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. An anchored expression on the host and path fixes it, and it is worth testing that a crafted URL fails to match before you rely on it.
- Know what the exclusion costs. The platform log was the only copy of the client address, the user agent and the response size. On a public route those are exactly the fields you want when something goes wrong, so this is a trade and not a free win. If it starts to matter, log those 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 your 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.
Scope
What we deliberately left out
No ATTENDEE, no ORGANIZER, no METHOD in the .ics
A downloaded file carrying ATTENDEE reads as an invitation in some clients and as an ordinary appointment in others, and it puts a colleague’s address in a file that sits in a Downloads folder. Inviting the other person is the Google link’s job, where the person clicking is the one doing the inviting.
No signature on the token
The identifier already carries 48 bits from a random-bytes call, so forging one means guessing those bits. An HMAC would add a secret to rotate and to forget — ours sat unset for 26 days once — while protecting nothing: the only person who benefits from forging a pair member’s copy is that pair’s other member, who was sent the same details anyway.
No availability lookup
Knowing when two people are free 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.
Honestly
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, puts the suggested time in the introduction, and afterwards asks whether they met. Free for every team, no card.