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.
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.
Permalink to this section Related
- Building Automated Inspection Notification Systems — the parent guide whose events this feed mirrors, sharing the same revision counter.
- Handling Timezone and DST Edge Cases in Inspection Scheduling — the wall-clock and offset pitfalls behind
TZIDcorrectness. - Securing Municipal API Endpoints for Third-Party Integrations — least-privilege scoping for the per-contractor feed token.
- Inspection Scheduling & Field Operations — the parent track covering scheduling, notification, and field capture.