---
title: Apple Calendar (iCloud) Two-Way Sync via CalDAV
slug: apple-calendar-caldav-sync
category: Integrations
summary: Show a shared Apple/iCloud calendar inside your app and write events back onto it — no gems, no OAuth app, just Net::HTTP speaking CalDAV with an app-specific password.
tags: [caldav, icloud, apple, calendar, ics, integrations, net-http, no-gem]
status: stable
visibility: public
source_project: duke-homes.leo.llamapress.ai
layers: [model, controller, view]
related:
  - title: Google Calendar Integration
    url: /cookbook/google-calendar-integration
    summary: The Google-side equivalent — OAuth + the official Calendar API.
---

# Apple Calendar (iCloud) Two-Way Sync via CalDAV

> ⚠️ **Cookbook example — not live code.** Every code block below is an **example
> snippet**, **not part of the llamapress.ai codebase**, and **not running on this
> server**. This is a reference recipe for a **Leo instance (an AI coding agent) to
> implement in its own app** — read it to understand the pattern, then recreate it
> there. (A copy-ready install package lives in
> `app/views/cookbook/apple-calendar-caldav-sync/` — see its `INSTALL.md`.)

Apple has **no public REST API for iCloud Calendar** — but it runs a standards-compliant
**CalDAV** server at `caldav.icloud.com`, the same one Thunderbird and every third-party
calendar app use. That means a Rails app can read AND write a customer's shared Apple
calendar with nothing but `Net::HTTP` and Nokogiri (both ship with Rails — important on
Leo boxes, where you cannot add gems). Auth is the customer's Apple ID plus an
**app-specific password** they mint at account.apple.com in about a minute.

The result: a `/calendar` page with a month grid + upcoming list fed live from iCloud,
and an "Add event" form whose events land on the shared calendar — and on everyone's
iPhones — seconds later. Proven end-to-end (read + create + delete round-trip) against a
real iCloud account.

> **When to use:** a client's team lives on a shared Apple calendar and wants those
> events inside the app, or wants the app to put events onto it. Works for any calendar
> the Apple ID owns or can edit.
> **When not to:** the client is on Google Calendar (use the Google integration — real
> API, webhooks). Or you need push updates — CalDAV here is poll-on-page-load; there are
> no webhooks.

---

## The 80/20 in one breath

1. Copy `AppleCalendarClient` (the install package has it verbatim) — discovery, read,
   write, ICS parse/build in one ~250-line service, zero gems.
2. One singleton table `apple_calendar_settings` (apple_id, app_password, calendar_url,
   calendar_name) + model with `.current` / `connected?`.
3. One controller: `show` (month grid), `settings`/`connect`/`select_calendar`
   (three-step connect flow), `create_event`, `disconnect`.
4. Three views: month grid + add-event modal, a settings page with the
   app-specific-password walkthrough, and a calendar picker.
5. Six routes under `/calendar`, one nav link.
6. The customer mints an app-specific password and connects on `/calendar/settings`.

---

## Layer 1 — How the CalDAV conversation works (the part worth understanding)

Four HTTP verbs against `https://caldav.icloud.com/`, all Basic-auth'd with
`apple_id:app_specific_password`:

```ruby
# app/services/apple_calendar_client.rb (excerpts — full file in the install package)

# 1) WHO AM I — PROPFIND / for the principal URL
PRINCIPAL_XML = <<~XML.freeze
  <d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>
XML

# 2) WHERE ARE MY CALENDARS — PROPFIND the principal for calendar-home-set,
#    then PROPFIND Depth:1 on the home to list calendars. Keep only real
#    VEVENT-capable calendars (skip inbox/outbox/reminders):
next unless node.at_xpath(".//*[local-name()='resourcetype']/*[local-name()='calendar']")
comps = node.xpath(".//*[local-name()='supported-calendar-component-set']/*[local-name()='comp']").map { |c| c["name"] }
next if comps.any? && !comps.include?("VEVENT")

# 3) READ — REPORT calendar-query with a time window; <c:expand> makes APPLE
#    expand recurring events into individual occurrences (no RRULE engine needed):
def query_xml(from_utc, to_utc, expand:)
  data = expand ? %(<c:calendar-data><c:expand start="#{from_utc}" end="#{to_utc}"/></c:calendar-data>) : "<c:calendar-data/>"
  <<~XML
    <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
      <d:prop><d:getetag/>#{data}</d:prop>
      <c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
        <c:time-range start="#{from_utc}" end="#{to_utc}"/>
      </c:comp-filter></c:comp-filter></c:filter>
    </c:calendar-query>
  XML
end

# 4) WRITE — PUT a minimal VCALENDAR to <calendar_url><uuid>.ics
#    with If-None-Match: * (create-only, never overwrite).
#    DELETE on that same URL removes the event (204).
```

Two infrastructure details the request core must handle (the package file does):

```ruby
# iCloud 3xx-redirects you to a partition host (p112-caldav.icloud.com) —
# follow redirects AND resolve returned hrefs against the FINAL uri, not BASE_URL.
# And because every request carries the credentials, hard-refuse foreign hosts:
ALLOWED_HOST = /(\A|\.)icloud\.com\z/
raise Error, "Refusing to send Apple credentials to #{uri.host}" unless uri.host.to_s.match?(ALLOWED_HOST)
```

## Layer 2 — Model & migration

```ruby
# app/models/apple_calendar_setting.rb — singleton row
class AppleCalendarSetting < ApplicationRecord
  # Password is a PLAIN column ON PURPOSE: Rails `encrypts` derives its key from
  # SECRET_KEY_BASE, which rotates on Leo-instance sleep/wake — an encrypted
  # password would silently brick on the next wake. App-specific passwords are
  # revocable at account.apple.com, so plain is the safer fleet trade.
  validates :calendar_url, format: { with: %r{\Ahttps://[\w.-]*icloud\.com[/:]}, allow_blank: true }

  def self.current = first || create!
  def credentialed? = apple_id.present? && app_password.present?
  def connected? = credentialed? && calendar_url.present?
  def client = AppleCalendarClient.new(apple_id: apple_id, app_password: app_password)
end
```

```ruby
# db/migrate/<timestamp>_create_apple_calendar_settings.rb
create_table :apple_calendar_settings do |t|
  t.string :apple_id, :app_password, :calendar_url, :calendar_name
  t.datetime :connected_at
  t.timestamps
end
```

## Layer 3 — Controller flow

```ruby
# app/controllers/calendar_controller.rb (shape — full file in the package)
# show:            month grid; fetch events for the visible grid window, live, per load
# settings:        the walkthrough + credential form
# connect (POST):  verify creds by running discovery; on success SAVE creds and
#                  render the calendar picker  ← this render is why the form is turbo:false
# select_calendar: save the chosen {name, url}; done
# create_event:    build times from the form (all-day or timed), PUT to iCloud, redirect
# disconnect:      nil out the row
def show
  @month = parse_month
  return unless @settings.connected?
  grid_from = @month.beginning_of_month.beginning_of_week(:sunday)
  @grid_days = (grid_from..@month.end_of_month.end_of_week(:sunday)).to_a
  events = @settings.client.events(@settings.calendar_url,
                                   from: @grid_days.first.in_time_zone,
                                   to: (@grid_days.last + 1).in_time_zone)
  @events_by_date = index_by_date(events)   # multi-day events on every day they span
  @upcoming = events.select { |e| (e.ends_at || e.starts_at) >= Time.zone.now }.first(12)
rescue AppleCalendarClient::AuthError => e
  @calendar_error = e.message               # amber banner + "Reconnect" link, never a 500
end
```

## Layer 4 — The views

Full files in the package: a 7-column month grid (all-day events as solid pills, timed
ones outlined with the start time, "+N more" past three, today badged, sideways-scroll
on phones), a "Coming up" list that doubles as the phone view, an add-event modal in
plain delegated JS (no Stimulus dependency), and the settings/picker pages. All Tailwind
+ Font Awesome FREE icons.

---

## Gotchas (the hard-won stuff)

- **Turbo eats the picker page — a user connects and "nothing happens."** The `connect`
  action *renders* the calendar picker on success, and Turbo silently drops non-redirect
  responses to form posts. Symptom in production: the loading bar flashes, the page
  doesn't change, yet the credentials SAVED (discovery ran before the render). The
  connect form must carry `data: { turbo: false }`. This shipped broken once; don't
  repeat it.
- **The user's normal Apple password can never work** — CalDAV requires an
  app-specific password (account.apple.com → Sign-In and Security → App-Specific
  Passwords). Say so in the UI or every first attempt fails.
- **`DTEND` is EXCLUSIVE.** A one-day all-day event has `DTEND` = the NEXT date. Add a
  day when writing all-day events; subtract one when deciding which grid days an event
  occupies, or every all-day event paints one day too many.
- **Let Apple expand recurrences.** `<c:expand>` in the REPORT returns individual
  occurrences, so you never write an RRULE engine. Keep a no-expand retry fallback and
  flag masters `recurring`.
- **Parse `...Z` timestamps with `Time.utc` explicitly** — `Time.strptime` without zone
  info assumes process-local time and shifts every event.
- **ICS text is folded and escaped**: continuation lines start with a space/tab (unfold
  before parsing), and `\n` `\,` `\;` `\\` need unescaping (and escaping on write). Skip
  `STATUS:CANCELLED` events.
- **Follow redirects to the partition host** and resolve DAV `href`s against the final
  URI. And **refuse to send credentials to any non-icloud.com host** — the stored
  `calendar_url` is user-influencable, and every request carries Basic auth.
- **Don't `encrypts` the password on a Leo box** — `SECRET_KEY_BASE` rotates on
  sleep/wake and bricks encrypted columns. Plain + revocable beats encrypted + bricked.
- **`allow_browser versions: :modern` blocks iPhones below iOS 17.2** with a dead
  "browser not supported" page — and a calendar is a phone page. Override the check in
  this controller.
- **Latency:** the page fetches iCloud live on every load (~1–2s). Fine for an internal
  tool; add a cache table if it ever isn't. There are no webhooks in CalDAV-land.

---

## Files this pattern touches

```
app/services/apple_calendar_client.rb        # the CalDAV client (verbatim drop-in)
app/models/apple_calendar_setting.rb         # singleton connection row
app/controllers/calendar_controller.rb
app/views/calendar/show.html.erb             # month grid + upcoming + add-event modal
app/views/calendar/settings.html.erb         # walkthrough + credential form (turbo:false)
app/views/calendar/choose.html.erb           # calendar picker
db/migrate/<timestamp>_create_apple_calendar_settings.rb
config/routes.rb                             # six routes under /calendar
```

## How to adapt to your schema

1. The client and model are drop-ins — rebrand only the `PRODID` string.
2. Point `CalendarController` at your app's base controller / layout; keep the
   `allow_browser` override.
3. Restyle the views to your app's palette; the grid logic (`@grid_days`,
   `@events_by_date`) is view-agnostic.
4. Deleting events from the app: `client.send(:request, :delete,
   "#{calendar_url}#{uid}.ics")` returns 204 — promote it to a public
   `delete_event(calendar_url, uid)` if you build event management UI.
5. Multiple calendars: drop the singleton (`.current`) pattern for a
   `has_many`-style table keyed by calendar_url; the client doesn't care.
6. Read-only variant with zero credentials: skip all of this and subscribe to the
   calendar's public `webcal://` URL instead — fetch and parse the ICS with the same
   `parse_ics`.
