Generating ICS Calendar Events for Lease Renewal Deadlines

Once the Calendar Event Generation stage has computed which dated obligations a lease owes — the notice window, the option-exercise deadline, the expiration — one concrete question remains before a property manager ever sees them: how do you serialize those events into a file a calendar client will actually import correctly, and re-import without spawning duplicates? This page answers exactly that: emitting valid, standards-compliant RFC 5545 iCalendar VEVENT records for lease renewal and notice deadlines using Python’s icalendar library, so that a .ics feed subscribed in Outlook, Google Calendar, or Apple Calendar shows the right reminder on the right day and updates in place when an amendment moves it.

The distinction that matters is between deciding the date and encoding the event. The upstream engine already resolved a notice deadline to a specific business-day-adjusted calendar date anchored to the property’s timezone; here we are purely concerned with the wire format. Get a required VEVENT property wrong — a missing DTSTAMP, an unstable UID, a DTSTART emitted as a DATE-TIME when it should be an all-day DATE — and a technically “generated” event either fails to import, drifts across a timezone boundary, or lands as a second copy alongside the one you published last week. The iCalendar spec is unforgiving in precisely the places lease deadlines are most sensitive.

Architectural context

This is the serialization tail of Calendar Event Generation inside the broader Downstream Automation & Sync domain. It sits strictly downstream of canonicalization: it never parses a document and never re-derives a clause. It consumes the validated CalendarEvent objects the generator produces — each already carrying a deterministic UID, an anchored starts_at, an all_day flag, a severity, and a lead time — and turns each into RFC 5545 text. The dates themselves trace back to the canonical lease data models, and any term that was missing or ambiguous was already resolved or flagged by fallback routing logic before it reached us, so the serializer never has to guess. When a serialized event is high-severity and its window is closing unacknowledged, it is critical date alerting — not this layer — that raises the escalation.

The stable UID carried down from the generator is what makes the whole ICS story work. Because it is a pure hash of lease_id + event_type + date, a calendar client treats a re-published event with the same UID as an update to the existing entry rather than a new one. That single property is the difference between a trustworthy renewal feed and a calendar slowly filling with duplicate “give notice” reminders every time the nightly rebuild runs.

Canonical lease dates serialized into VEVENTs with VALARM reminders and published as an ICS feed On the left, a canonical lease supplies three resolved dates: an option-exercise deadline, a business-day-adjusted notice deadline, and an expiration. Each flows into a central VEVENT builder that uses Python's icalendar library to assign required RFC 5545 properties: a stable UID hashed from lease id, event type, and date; a DTSTAMP generation timestamp; a DTSTART emitted as VALUE equals DATE for all-day obligations; a SUMMARY and DESCRIPTION; and a SEQUENCE number taken from the amendment version. Attached to each VEVENT is a VALARM lead-time ladder firing reminders at ninety days and thirty days before the hard date. The completed VEVENTs are gathered into a VCALENDAR and published two ways: as a single subscribable ICS feed at a stable URL, and as per-event .ics files. These are consumed by calendar clients — Outlook, Google, and Apple — which deduplicate by UID so a re-published event updates in place. A separate amendment path re-enters the builder with the same UID and a bumped SEQUENCE to revise the already-published event. Canonical lease resolved dates option deadline notice deadline expiration VEVENT builder icalendar · RFC 5545 UID = hash(lease+type+date) DTSTAMP · DTSTART DATE SUMMARY · DESCRIPTION SEQUENCE = amendment ver VALARM ladder T−90 · T−30 ICS feed one subscribable URL Per-event .ics one file per obligation Calendar clients Outlook · Google Apple dedup by UID Amendment moves date same UID, bump SEQUENCE revise in place
Resolved lease dates enter a VEVENT builder that assigns RFC 5545 properties, a stable UID, a SEQUENCE from the amendment version, and a VALARM lead-time ladder; the VEVENTs publish as an ICS feed and per-event files that clients deduplicate by UID, while an amendment revises the published event in place with a bumped SEQUENCE.

Required VEVENT properties, and what each means for a lease

RFC 5545 mandates a small set of properties on every VEVENT; iCalendar clients silently reject or mis-render events that omit them. The table below maps each to its lease meaning so the serializer emits nothing decorative and nothing missing.

Property Required? What it carries for a lease deadline
UID Yes Stable identity — the deterministic lease_id + type + date hash that makes re-publishing an update, not a duplicate
DTSTAMP Yes When this version of the event was generated (UTC); distinct from the deadline itself
DTSTART Yes The obligation’s date — VALUE=DATE for an all-day deadline, a zoned DATE-TIME only for genuinely timed events
SUMMARY Recommended The one-line calendar title, e.g. “Give notice to renew — Suite 400”
DESCRIPTION Optional Context: lease id, clause reference, the hard deadline the reminder precedes
SEQUENCE Optional Revision counter; bumped from the amendment version so clients accept the newer copy
VALARM Optional Embedded reminder(s) with a TRIGGER offset — the T−90 / T−30 lead-time ladder
DTEND / DURATION Optional Usually omitted for all-day deadlines; a single-day DATE start is sufficient

Two of these deserve emphasis because they are the ones most often botched for leases. DTSTAMP is not the deadline — it is the moment the record was produced, and clients use it together with SEQUENCE to decide which of two same-UID copies is newer. And DTSTART must be VALUE=DATE (a bare 20261231, no time, no zone) for an all-day obligation; emitting a DATE-TIME here is what makes a “notice due” event appear to a user in London an entire day off from the same event as seen in Chicago.

Feed shape: one subscribable ICS vs per-event files vs CalDAV

There are three ways to deliver these VEVENTs, and the choice is orthogonal to how you build them.

Delivery How it reaches the user Best for Cost
Subscribable ICS feed One VCALENDAR at a stable URL the client polls A living portfolio calendar that refreshes as leases change Client controls poll interval; updates lag
Per-event .ics files One file per obligation, emailed or attached One-off “add this deadline” hand-offs No live sync; each is a static snapshot
CalDAV push Server writes events into the user’s calendar collection Real-time, two-way, acknowledgement tracking Auth + a CalDAV server; most operational weight

For a renewal calendar the subscribable feed is almost always right: publish a single VCALENDAR per property or per portfolio at a stable URL, let Outlook or Google poll it, and let the stable UIDs do the deduplication. Per-event files are for hand-offs — attaching a single deadline to an email. CalDAV is the heavyweight option when you need the calendar to also record that a human saw the reminder, which is really the province of critical date alerting rather than plain serialization.

The serializer below takes the CalendarEvent objects produced upstream and builds a VCALENDAR. It emits each required property, sets VALUE=DATE for all-day deadlines, attaches a VALARM for each lead time, and derives SEQUENCE from the amendment version so a revised lease supersedes the copy already sitting in a client.

from __future__ import annotations

from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Optional

from icalendar import Alarm, Calendar, Event
from pydantic import BaseModel, Field, field_validator


class RenewalEvent(BaseModel):
    """A resolved obligation handed down from the calendar generator."""
    uid: str                              # deterministic: lease_id + type + date
    lease_id: str
    summary: str
    description: str
    on: date                              # the hard deadline, already business-day adjusted
    all_day: bool = True
    lead_days: tuple[int, ...] = (90, 30) # VALARM ladder, T-90 / T-30
    amendment_version: int = Field(default=0, ge=0)  # -> SEQUENCE

    @field_validator("uid")
    @classmethod
    def _uid_is_stable(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("UID must be a stable, domain-qualified identifier")
        return v


def build_vevent(ev: RenewalEvent, tzname: str = "America/Chicago") -> Event:
    vevent = Event()
    vevent.add("uid", ev.uid)
    # DTSTAMP is when THIS version was generated, in UTC — not the deadline.
    vevent.add("dtstamp", datetime.now(tz=timezone.utc))
    vevent.add("summary", ev.summary)
    vevent.add("description", ev.description)
    # SEQUENCE from the amendment version: a later amendment supersedes the
    # copy already in the client, so bumping it forces clients to accept the update.
    vevent.add("sequence", ev.amendment_version)

    if ev.all_day:
        # VALUE=DATE — a bare calendar date, no time, no zone. icalendar emits
        # VALUE=DATE automatically when given a date rather than a datetime.
        vevent.add("dtstart", ev.on)
    else:
        from zoneinfo import ZoneInfo
        start = datetime(ev.on.year, ev.on.month, ev.on.day, 9, 0,
                         tzinfo=ZoneInfo(tzname))
        vevent.add("dtstart", start)  # zoned DATE-TIME; icalendar emits a VTIMEZONE

    # VALARM lead-time ladder: one DISPLAY alarm per lead, triggered before DTSTART.
    for days in sorted(ev.lead_days, reverse=True):
        alarm = Alarm()
        alarm.add("action", "DISPLAY")
        alarm.add("description", f"{days} days: {ev.summary}")
        alarm.add("trigger", -_ical_duration(days))  # negative = before start
        vevent.add_component(alarm)

    return vevent


def _ical_duration(days: int):
    from datetime import timedelta
    return timedelta(days=days)


def build_feed(events: list[RenewalEvent], tzname: str = "America/Chicago") -> bytes:
    cal = Calendar()
    cal.add("prodid", "-//leaseautomation.org//Renewal Feed//EN")
    cal.add("version", "2.0")
    cal.add("method", "PUBLISH")
    for ev in events:
        cal.add_component(build_vevent(ev, tzname=tzname))
    return cal.to_ical()   # RFC 5545 text, ready to serve at a stable URL


if __name__ == "__main__":
    notice = RenewalEvent(
        uid="[email protected]",
        lease_id="L-4821",
        summary="Give notice to renew — Suite 400",
        description="Lease L-4821: deliver written renewal notice before the option deadline of 2026-12-31.",
        on=date(2026, 7, 3),          # notice deadline, already shifted off the July 4 holiday
        amendment_version=2,          # this lease has been amended twice -> SEQUENCE:2
    )
    print(build_feed([notice]).decode())

The all_day branch is doing the load-bearing work: handing icalendar a date object produces DTSTART;VALUE=DATE:20260703, while handing it a zoned datetime produces a DATE-TIME and an accompanying VTIMEZONE block. The SEQUENCE line is the amendment story in one field — a lease amended twice serializes as SEQUENCE:2, and because the UID is unchanged, every client treats the incoming copy as a strictly newer revision of the event it already holds and overwrites it in place. Money math like a percentage rent breakpoint or a step-up amount, if it belongs in the DESCRIPTION, should be formatted from a Decimal upstream so no float rounding ever reaches the human-readable text.

Edge cases specific to lease deadlines

  • All-day date vs datetime. A renewal deadline is an all-day obligation; emit DTSTART;VALUE=DATE. The instant you emit a DATE-TIME for it, the event shifts by up to a day for any subscriber in a different timezone from the property — the single most common ICS lease bug.
  • DST on the rare timed reminder. If you do model a timed event (say a 9 a.m. inspection window), anchor it with a VTIMEZONE/zoneinfo zone rather than a fixed UTC offset. A hard -06:00 offset will be an hour wrong for half the year once the property crosses a daylight-saving boundary.
  • Amendment moves the date → same UID, bump SEQUENCE. When an amendment shifts the option deadline, keep the same UID only if the event’s identity date is unchanged; when the date itself moves, the generator mints a new UID for the moved event, and the retired UID must be explicitly deleted from the feed. Within a single obligation whose metadata changes, bump SEQUENCE so clients accept the revision. The precedence of which amendment wins is resolved upstream via escalation formula mapping and the canonical lease data models, never inside the serializer.
  • Leap-year notice math. A “180 days before” notice window that crosses February 29 must be computed with real date arithmetic (timedelta), never by subtracting a fixed 6 months; the upstream engine already does this, and the serializer must not “helpfully” re-derive it.
  • Calendar-client dedup by UID. Outlook, Google, and Apple all key on UID. If two events for the same obligation carry different UIDs — because the generator was non-deterministic — the client shows both. A stable, reproducible UID is the entire defense.

When to escalate

Serialization is a pure formatting step, and it should refuse to invent structure to cover a bad input. Escalate out of the emit path when:

  • The notice window is unparseable or ambiguous — a renewal option present in the lease text with no explicit notice count, or two amendments that disagree on the deadline. Do not serialize a guessed date; route the record through fallback routing logic for a human to resolve the canonical source first.
  • A resolved deadline is already inside its window on first emission — that is not a feed entry, it is an alert; hand it to critical date alerting and, when appropriate, on to escalating missed notice windows to manual review.
  • A timezone name will not resolve or a date is naive — the pydantic gate should already have rejected it upstream, but the serializer must fail loudly rather than emit a DTSTART a client will silently misplace.

Frequently asked questions

Why does DTSTART need VALUE=DATE for renewal deadlines? A lease renewal or notice deadline is an all-day obligation, not a meeting at a moment. Emitting DTSTART;VALUE=DATE with a bare calendar date keeps every subscriber seeing the same day regardless of their timezone. A DATE-TIME start attaches an instant that shifts by up to a day across timezone and DST boundaries, which is how a “notice due” event ends up on the wrong date for a remote user.

How does a stable UID stop calendar clients from creating duplicates? Outlook, Google, and Apple all treat the UID as the event’s identity. If you re-publish an event with the same UID, the client updates the existing entry in place instead of adding a second one. Because the upstream generator derives the UID as a pure hash of the lease id, event type, and date, re-running the serializer against an unchanged lease reproduces identical UIDs, so the feed is idempotent.

When do I bump SEQUENCE versus mint a new UID? Bump SEQUENCE when the same obligation is revised in place — a corrected summary, an added reminder, or an amendment that changes metadata while the identity date holds. Take the value straight from the lease’s amendment version so a later amendment always outranks the copy already in the client. Mint a new UID only when the date itself moves, and then explicitly delete the retired UID from the feed so the old reminder disappears.

Should I publish a subscribable feed or per-event .ics files? Publish a single subscribable VCALENDAR at a stable URL for a living portfolio calendar — clients poll it and the stable UIDs deduplicate automatically as leases change. Reserve per-event .ics files for one-off hand-offs, like attaching a single deadline to an email, since each is a static snapshot with no live sync.

← Back to Calendar Event Generation

← Back to Calendar Event Generation