How do you generate an ICS calendar feed for recurring subscriptions?

RRULE mapping from a unit-plus-multiplier interval, the UID rule that stops duplicates, and why calendar clients refresh less often than you expect.

Answer

Map the interval to an RRULE, give each subscription a stable UID that never changes, and serve the feed from a per-user token URL. The constraint nobody expects: calendar clients refresh on their own schedule, often only every few hours to a day, so a live feed is not live and the product copy should not claim it is.

LM

Leutrim Miftaraj

Founder, SubTracker · Updated September 5, 2026

The short answer

A calendar feed is the cheapest high-value feature in this category — renewals appear where people already look, with no notification to dismiss. The two things that break it are unstable UIDs, which duplicate every event on each refresh, and overpromising freshness the calendar client will not deliver.

Why this is worth building early

It is a few hundred lines and it puts renewal dates into the calendar the user already checks every day, without an app, a notification or a login.

For a free tier it is particularly good value: it delivers most of what a reminder does, at almost no operational cost — no email sending, no delivery failures, no suppression list. Everything that makes scheduled email hard is absent here.

Mapping the interval to an RRULE

With intervals stored as a unit plus a multiplier, the mapping is direct:

""`ts function rrule(unit: string, every: number, anchorDay: number): string | null { switch (unit) { case 'day': return "FREQ=DAILY;INTERVAL=${every}" case 'week': return "FREQ=WEEKLY;INTERVAL=${every}" case 'month': return "FREQ=MONTHLY;INTERVAL=${every};BYMONTHDAY=${anchorDay}" case 'year': return "FREQ=YEARLY;INTERVAL=${every}" case 'once': return null // a single VEVENT, no recurrence } } ""`

"BYMONTHDAY" with the anchor day is what makes a 31st-of-the-month subscription behave. Without it, clients apply their own rollover rule and you get the same drift problem as in the date arithmetic, except now it is happening inside somebody else's calendar app where you cannot see it.

"once" returning null rather than a one-occurrence rule keeps the output simpler and avoids a class of client bugs around COUNT=1.

The UID rule

Every VEVENT needs a UID that is stable for the lifetime of the subscription.

""` UID:subscription-<uuid>@subtracker.io ""`

Derive it from the subscription id, never from the date, the name or a hash of the content. If the UID changes between refreshes — because the user renamed the subscription, or because you hashed the amount into it — the calendar client treats it as a new event and keeps the old one. Refresh a few times and the user has five copies of the same renewal.

This is the single most common bug in calendar feeds and it is invisible from the server: the feed is correct every time, and the duplicates accumulate on the client.

Same rule for deletions. A subscription removed from the feed disappears from most clients on refresh, but not all — emitting an explicit cancellation is safer if you can:

""` METHOD:CANCEL STATUS:CANCELLED SEQUENCE:1 ""`

Increment SEQUENCE on any change to an existing event, or some clients ignore the update.

"Live" is not live

The feature people ask for is a feed that updates as their subscriptions change. What they get is a feed the calendar client fetches on its own schedule.

Google Calendar refreshes external feeds on an interval it decides, commonly measured in hours and sometimes up to a day. Apple Calendar lets the user pick, defaulting to something similar. Outlook is its own case.

You cannot push. There is no mechanism.

So two things follow. Do not claim real-time updates in product copy — it is a promise the client will not keep, and the support conversation that follows is entirely avoidable. "Updates automatically" is accurate; "updates instantly" is not.

And set sensible cache headers rather than fighting it:

""`ts return new Response(ics, { headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'Cache-Control': 'public, max-age=3600', }, }) ""`

Serving it safely

The feed URL is an unauthenticated GET — calendar clients do not do OAuth. So the URL itself is the credential.

A per-user random token in the path, not the user id. Long enough to be unguessable, and rotatable from settings so a user who shared a calendar can revoke it.

Nothing sensitive in the event body. Provider name and amount is what people want; do not include account identifiers or anything you would not put on a postcard. The URL will end up in a calendar sync log somewhere.

Rate-limit it. Calendar clients are polite but a leaked URL in a shared calendar can produce surprising traffic.

We also emit a one-time ".ics" download alongside the subscribed feed, because some people want a snapshot rather than a subscription — and because it is the same generator with the recurrence rules already computed.

Die ehrliche Bau-oder-Kauf-Linie

Das Schema ist der einfache Teil. Es ist auch der Teil, der sich nach Fortschritt anfühlt — weshalb das Projekt unmittelbar danach aufhört, Spass zu machen.

Was tatsächlich Zeit kostet, in der Reihenfolge, in der es zubeisst: geplante Erinnerungen mit Idempotenz und Zeitzonen, Wiederholungs-Arithmetik über unregelmässige Intervalle, Währungsbehandlung, und ein Import, aus dem heraus überhaupt etwas entsteht.

Nichts davon ist intellektuell schwierig. Es ist schwierig im Sinne von vier Wochenenden, und das vierte ist das, an dem das Projekt stirbt.

Wenn das Ziel Lernen ist: bau es. Der Code hier ist echt und funktioniert. Wenn das Ziel ist, kein Geld mehr an vergessene Abos zu verlieren, ist der Bau ein Umweg.

Was SubTracker damit macht

Zur Einordnung, weil die Entscheidungen oben aus einem laufenden System stammen und nicht aus einer Entwurfsübung.

SubTracker ist eine Next.js- und PostgreSQL-Anwendung (Supabase), die Abos manuell verfolgt — es gibt keine Bankverbindung, jede Zeile stammt aus einem Formular oder einem CSV-Import. Diese Einschränkung prägt das Schema: kein „erkannt"-Zustand, kein Konfidenzwert, keine Händler-Zuordnungstabelle. Jede Zeile ist eine Behauptung, die jemand aufgestellt hat.

Der kostenlose Plan verfolgt unbegrenzt viele Abos inklusive CSV-Import und Live-Kalender-Feed. Erinnerungen und Preis-Alarme sind Plus für $5.90/Monat; Family kostet $9.90/Monat und fügt einen geteilten Workspace für bis zu zehn Personen hinzu — an dem Punkt hören die Besitzverhältnisse im Schema auf, theoretisch zu sein.

Frequently asked questions

How do you convert a billing interval to an RRULE?+

Map the unit to FREQ and the multiplier to INTERVAL, and add BYMONTHDAY with the anchor day for monthly rules. Without BYMONTHDAY, clients apply their own month-end rollover and the dates drift inside the user's calendar where you cannot see it.

Why are events duplicating in the calendar?+

Almost always an unstable UID. If it is derived from anything that can change — the name, the amount, a content hash — the client treats each refresh as a new event and keeps the old one. Derive it from the subscription id alone.

How often do calendar clients refresh an external feed?+

On their own schedule, commonly hours and sometimes up to a day. There is no push mechanism, so product copy should say "updates automatically" rather than "instantly" — the second is a promise the client will not keep.

How do you secure an ICS feed URL?+

The URL is the credential, since calendar clients cannot authenticate. Use a long random per-user token in the path rather than the user id, make it rotatable from settings, and keep anything sensitive out of the event body.