Generating iCal Feeds for Contractor Inspection Appointments

This guide builds on Building Automated Inspection Notification Systems, part of the Inspection Scheduling & Field Operations track: where that parent guide fans an inspection event out across email and SMS, this walkthrough produces the standards-based calendar feed that rides alongside those messages so a contractor’s booking lands directly in their calendar app.

Permalink to this section Precise Problem Framing

A contractor who books a dozen municipal inspections a month does not want a dozen one-off .ics attachments they must each accept; they want one URL they subscribe to once, that then keeps itself current as visits are booked, moved, and cancelled. That is a calendar feed — an RFC 5545 VCALENDAR document, served over a stable webcal:// URL, that the client re-fetches on its own schedule. Get the feed’s identity discipline wrong and the failure is not cosmetic: a VEVENT whose UID changes on every generation produces duplicate entries instead of updating the original, a rescheduled visit whose SEQUENCE never increments is silently ignored by the client as a stale revision, and a cancellation that is dropped from the feed rather than marked STATUS:CANCELLED leaves a ghost appointment the contractor mobilizes a crew for.

The compliance stakes are the same as the messaging path: the calendar is a form of notice, and “the appointment was on your subscribed feed” only holds if the feed was correct and stable. The input characteristics are specific — appointment windows in a definite local timezone (inspections are wall-clock events, not UTC instants), a revision counter carried from the scheduling engine, and a per-contractor scope so one subscriber never sees another’s addresses. This guide produces that feed with the icalendar library, keeping UID stable, bumping SEQUENCE on every change, emitting STATUS:CANCELLED for released slots, and attaching VALARM reminders — all under the correct TZID.

One VEVENT UID revised across SEQUENCE bumps from booked to rescheduled to cancelled A single stable UID runs down the left margin. Three revisions of the same VEVENT sit along a timeline. Revision zero is the booking: SEQUENCE 0, STATUS CONFIRMED, an original start time. Revision one is a reschedule: SEQUENCE 1, STATUS CONFIRMED, a new start time, same UID. Revision two is a cancellation: SEQUENCE 2, STATUS CANCELLED, same UID. A calendar client re-fetches the feed, matches all three on the shared UID, and applies the highest SEQUENCE so the single entry updates in place instead of duplicating. Stable UID [email protected] never changes time → Booked SEQUENCE 0 STATUS CONFIRMED Rescheduled SEQUENCE 1 new DTSTART Cancelled SEQUENCE 2 STATUS CANCELLED client keys on UID · applies highest SEQUENCE

Permalink to this section Step 1 — Build a Stable VEVENT with a Durable UID and TZID

The UID is the primary key of a calendar entry. It must be globally unique, stable for the life of the inspection, and derived from the record, never from a timestamp or a random value regenerated each build. Anchor it to the immutable inspection id. Inspection windows are wall-clock local times — a 2 p.m. inspection is 2 p.m. regardless of what UTC offset the day happens to carry — so emit them with an explicit TZID rather than converting to a floating or UTC time.

from datetime import datetime
from zoneinfo import ZoneInfo
from icalendar import Calendar, Event, vText

LOCAL_TZ = ZoneInfo("America/Chicago")
UID_DOMAIN = "inspections.city.gov"


def build_event(inspection_id: str, permit_id: str, starts_at: datetime,
                ends_at: datetime, sequence: int, address: str,
                status: str = "CONFIRMED") -> Event:
    ev = Event()
    # Durable, record-derived UID: the SAME inspection always maps here, so a
    # reschedule updates this entry instead of spawning a duplicate.
    ev.add("uid", f"{inspection_id}@{UID_DOMAIN}")
    ev.add("summary", f"City inspection — permit {permit_id}")
    ev.add("location", vText(address))
    # DTSTAMP is when THIS revision was generated; DTSTART/DTEND carry TZID via
    # the tz-aware datetime, so clients render the correct wall-clock time.
    ev.add("dtstamp", datetime.now(tz=LOCAL_TZ))
    ev.add("dtstart", starts_at.astimezone(LOCAL_TZ))
    ev.add("dtend", ends_at.astimezone(LOCAL_TZ))
    ev.add("sequence", sequence)     # revision counter, see Step 2
    ev.add("status", status)         # CONFIRMED | CANCELLED
    return ev

Because the UID is a pure function of the inspection id, regenerating the entire feed from scratch on every request is safe — the contractor’s client re-matches each VEVENT to the one it already holds and reconciles, rather than accreting duplicates. The tz-aware dtstart makes the icalendar library serialize a TZID-qualified DTSTART plus the matching VTIMEZONE block, which is what carries daylight-saving transitions correctly; the deeper edge cases live in handling timezone and DST edge cases in inspection scheduling.

Permalink to this section Step 2 — Bump SEQUENCE on Change and Emit STATUS:CANCELLED

RFC 5545 uses SEQUENCE to order revisions of the same UID. A client only accepts an update whose SEQUENCE is greater than or equal to the copy it holds; forget to bump it and the reschedule is discarded as stale. Carry the same revision counter you used for notification idempotency straight through to the feed so the message stream and the calendar agree on which version is current. A cancellation is not a deletion from the feed — it is the highest-SEQUENCE revision with STATUS:CANCELLED, which is what instructs the client to strike the entry.

def revise_event(inspection_id: str, permit_id: str, starts_at: datetime,
                 ends_at: datetime, prior_sequence: int, address: str,
                 cancelled: bool = False) -> Event:
    # Every material change increments SEQUENCE; a client ignores an update whose
    # SEQUENCE is not higher than the version it already stored.
    next_sequence = prior_sequence + 1
    status = "CANCELLED" if cancelled else "CONFIRMED"
    ev = build_event(inspection_id, permit_id, starts_at, ends_at,
                     sequence=next_sequence, address=address, status=status)
    if cancelled:
        # A cancelled event stays in the feed so subscribers converge on the
        # cancellation; dropping it silently leaves a ghost appointment behind.
        ev.add("method", vText("CANCEL"))
    return ev

The rule of thumb: bump SEQUENCE for any change the contractor would care about — time, location, or cancellation — and leave it untouched for cosmetic regenerations. Persist the last emitted SEQUENCE per inspection alongside the record so a rebuild after a process restart does not reset it to zero, which would make every client reject the “downgraded” revision.

Permalink to this section Step 3 — Attach VALARM Reminders

A VALARM is a client-side reminder nested inside the VEVENT. For inspections, a display alarm at a relative trigger (for example, one hour before start) prompts the contractor to have the site accessible. Keep triggers relative to DTSTART so a reschedule automatically moves the reminder with the event — an absolute trigger would fire at the old time after a change.

from datetime import timedelta
from icalendar import Alarm


def add_reminders(ev: Event, cancelled: bool = False) -> Event:
    if cancelled:
        return ev   # no reminders on a cancelled visit
    for lead in (timedelta(days=-1), timedelta(hours=-1)):
        alarm = Alarm()
        alarm.add("action", "DISPLAY")
        alarm.add("description", "Upcoming city inspection — ensure site access")
        # RELATIVE trigger: moves with DTSTART on a reschedule automatically.
        alarm.add("trigger", lead)
        ev.add_component(alarm)
    return ev

Two relative alarms — a day out and an hour out — cover the common “forgot entirely” and “forgot today” cases without spamming. Because the trigger is relative, Step 2’s reschedule carries the reminders to the new time with no extra bookkeeping.

Permalink to this section Step 4 — Assemble and Serve a Per-Contractor webcal Feed

Wrap the events in a VCALENDAR with the required PRODID and VERSION, then serve it scoped to a single contractor behind an unguessable subscription token — never a sequential id, which would let one subscriber enumerate another’s schedule and site addresses. Serve it read-only with a short cache lifetime so clients see updates promptly, and offer the URL with the webcal:// scheme so a single click subscribes.

from fastapi import FastAPI, Response, HTTPException

app = FastAPI()


def build_feed(events: list[Event]) -> bytes:
    cal = Calendar()
    cal.add("prodid", "-//City of Example//Inspection Feed//EN")
    cal.add("version", "2.0")          # RFC 5545 version marker
    cal.add("calscale", "GREGORIAN")
    cal.add("x-wr-calname", "City Inspections")
    for ev in events:
        cal.add_component(ev)
    return cal.to_ical()               # CRLF-terminated, folded per spec


@app.get("/feeds/inspections/{token}.ics")
def contractor_feed(token: str) -> Response:
    contractor = resolve_subscription_token(token)   # opaque token -> party_id
    if contractor is None:
        raise HTTPException(status_code=404)          # never 403: no oracle
    events = load_current_events(contractor)          # includes cancellations
    body = build_feed(events)
    return Response(content=body, media_type="text/calendar",
                    headers={"Cache-Control": "max-age=900, private"})

Returning 404 rather than 403 for an unknown token denies an attacker a confirm/deny oracle on which tokens exist. The scoping to one contractor is the same least-privilege discipline the platform applies when securing municipal API endpoints for third-party integrations — a feed URL is a bearer credential and must reveal exactly one subscriber’s data.

Permalink to this section Parameter and Flag Reference

Property / flag Value Rationale for inspection feeds
UID {inspection_id}@domain Record-derived and stable; the key clients use to update in place.
SEQUENCE monotonic int Must increase on every material change or the client ignores the update.
DTSTAMP generation time When this revision was built; distinct from DTSTART.
DTSTART / DTEND tz-aware, TZID Wall-clock inspection window; emit local, not UTC or floating.
STATUS CONFIRMED / CANCELLED Cancellation stays in the feed as a status, not a deletion.
METHOD CANCEL on cancel Signals the client to strike the entry.
VALARM trigger relative -PT1H, -P1D Moves with DTSTART on reschedule; absolute triggers fire stale.
PRODID / VERSION required VERSION:2.0 marks RFC 5545; a missing PRODID is non-conformant.
Cache-Control max-age=900, private Prompt refresh without hammering the endpoint; per-subscriber, not shared.
feed token opaque, unguessable Prevents enumeration of other contractors’ schedules and addresses.

Permalink to this section Common Failure Patterns and Fixes

Permalink to this section Duplicate events instead of updates

The most common defect: the UID is regenerated (from a timestamp, a uuid4(), or a row’s autoincrement) each build, so the client treats every fetch as a brand-new event and the contractor’s calendar fills with copies. Derive the UID purely from the inspection id and never let it vary across builds.

# WRONG: a fresh UID every generation -> duplicates pile up
ev.add("uid", f"{uuid4()}@city.gov")
# RIGHT: stable, record-derived UID -> updates reconcile in place
ev.add("uid", f"{inspection_id}@{UID_DOMAIN}")

Permalink to this section Reschedule silently ignored

A client keeps the old time because the revised VEVENT carried the same (or a lower) SEQUENCE. Persist the last emitted sequence and increment it on every material change; treat a missing stored value as the current max, not zero, after a restart.

Permalink to this section Cancellation leaves a ghost appointment

Removing the VEVENT from the feed does not cancel it in most clients — they retain the last copy they saw. Emit the cancellation as the highest-SEQUENCE revision with STATUS:CANCELLED and keep it in the feed until it is safely in the past.

Permalink to this section Wrong wall-clock time after DST

Serializing DTSTART as UTC or as a floating time makes the appointment drift by an hour across a daylight-saving boundary. Always emit tz-aware local datetimes so the library writes a TZID and the accompanying VTIMEZONE, and let the client resolve the offset.

Permalink to this section Unfolded or LF-only lines rejected

Hand-built iCalendar text that uses bare \n or exceeds 75 octets without line folding is rejected by strict parsers. Always serialize with Calendar.to_ical() rather than string-formatting, so folding and CRLF terminators are handled per spec.

Permalink to this section Audit and Logging Guidance

Treat the feed as a form of legal notice and log accordingly. Record each feed generation and each SEQUENCE bump with the inspection id, the resulting sequence, and the resulting STATUS, so a dispute over “was the cancellation on my calendar?” is answerable from the record. Log feed fetches at the subscription-token level — resolved to the party_id, never the raw token — with a timestamp and response status, which establishes that a current feed was available for the contractor to pull. Redact site addresses and any PII from log lines; the audit trail proves availability and revision history, it does not need to duplicate the payload. Retain these entries for the state-mandated period alongside the messaging delivery ledger from the parent notification system, so the calendar and the email/SMS record together answer one question: was the contractor notified of this revision, and when.

Permalink to this section Frequently Asked Questions

Permalink to this section Why does my rescheduled inspection show up as a second event instead of moving?

The UID changed between builds. A calendar client matches revisions by UID; if it differs, the client cannot know the two events are the same appointment and keeps both. Derive the UID deterministically from the inspection id so every regeneration produces the identical value and the client updates in place.

Permalink to this section Do I delete a cancelled inspection from the feed?

No. Removing the VEVENT leaves most clients holding the last copy they fetched, producing a ghost appointment. Keep the event in the feed as the highest-SEQUENCE revision with STATUS:CANCELLED (and METHOD:CANCEL) until it is comfortably in the past, then it can age out.

Permalink to this section Should inspection times be stored and served in UTC?

Store the instant however your database prefers, but serve the feed as tz-aware local time with an explicit TZID. Inspections are wall-clock events — a 2 p.m. visit stays 2 p.m. across a DST change — and emitting UTC or floating times makes clients render the wrong hour after a daylight-saving transition.