Files
Smart-Tablet-Screen/docs/research/smart-tablet-screen-calendar-webhooks.md
T
haylanandClaude-Bot 07670c5f44 docs(research): calendar webhook support + FrankenPHP Docker setup
Resolves wayfinder tickets #2 and #3 on the calendar-backend
groundwork map (#1):

- smart-tablet-screen-calendar-webhooks.md: Nextcloud CalDAV push
  support (none usable — Mail-in-a-Box's bundled Nextcloud predates
  the Webhooks app) vs Microsoft Graph webhook subscriptions
  (supported, with renewal-job requirements).
- smart-tablet-screen-frankenphp-docker.md: FrankenPHP-based dev/prod
  Docker image and compose setup for the Symfony backend.

Also gitignore .scratch/, the repo-local scratchpad directory for
throwaway working files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QshNimeB3TVU2vMjN7tEuz
2026-09-01 21:37:53 +02:00

11 KiB
Raw Blame History

Smart Tablet Screen — Calendar Webhook vs Polling Research

Research date: 2026-09-01. All claims below are sourced against primary/official documentation, source code, or first-party API references — not third-party summaries. Each claim links its own source. Companion to docs/research/smart-tablet-screen-stack.md (§12 cover the base CalDAV/Graph decision this doc extends for change-detection strategy) — written for issue #2, feeds issue #5 ("Cache refresh trigger mechanism").


1. Nextcloud CalDAV (Mail-in-a-Box bundled): webhook vs sync-token

  • Mail-in-a-Box pins an old Nextcloud version. Its own setup script hardcodes nextcloud_ver=27.1.11 (with a matching SHA1 for the downloaded tarball) — this is the version actually installed on any Mail-in-a-Box box today, not whatever is "latest" upstream. — mail-in-a-box/mailinabox setup/nextcloud.sh, GitHub
  • Nextcloud's own "Webhooks" capability (webhook_listeners app) only exists from Nextcloud 30.0.0 onward. It ships as an app inside the nextcloud/server monorepo itself (apps/webhook_listeners/, added by PR #45475) — it is not a separately-maintained GitHub repo (nextcloud/webhook_listeners does not exist as an independent project; the app lives at nextcloud/server/apps/webhook_listeners). — nextcloud/server PR #45475, GitHub, nextcloud/server apps/webhook_listeners, GitHub
  • Consequence: Mail-in-a-Box's Nextcloud 27.1.11 predates webhook_listeners by three major versions (27 → 30). The app cannot be present or enabled on a Mail-in-a-Box-bundled Nextcloud instance today — it isn't a matter of the household needing to install anything extra; the underlying Nextcloud version doesn't ship the feature at all. This directly answers the ticket's "is it realistic to assume it's present" question: no.
  • Even where present (Nextcloud ≥30), webhook_listeners does support calendar/event change events. Nextcloud's own admin manual lists supported event categories including calendar object creation, update, deletion, and movement between calendars (CalendarObjectCreatedEvent, CalendarObjectUpdatedEvent, CalendarObjectDeletedEvent, CalendarObjectMovedEvent), alongside file, tag, Forms, and Tables events. It comes bundled with core Nextcloud but is not enabled by default — an admin must run occ app:enable webhook_listeners, and registering a webhook additionally requires an admin (or delegated-admin) account via the OCS API. Delivery itself runs through a background job, triggered roughly every 5 minutes by default rather than instantly. — Webhook Listeners, Nextcloud Administration Manual, docs.nextcloud.com
  • Absent (or unusable) webhooks, Nextcloud's CalDAV implementation supports WebDAV Collection Sync per RFC 6578 — the standard, protocol-level alternative to full re-fetch. The client requests a sync-token via a sync-collection REPORT; the server replies only with items created/modified/deleted since that token, plus a new token to store for the next round. This is a generic CalDAV/WebDAV mechanism (not Nextcloud-specific), defined by the IETF standard itself. — RFC 6578 — Collection Synchronization for WebDAV, datatracker.ietf.org
  • This sync-token mechanism still requires a client-initiated poll — RFC 6578 makes each poll cheap (only the delta is returned) but does not give the server a way to push to the client; the client must periodically re-issue the sync-collection REPORT to learn whether anything changed. There is no server-initiated notification path in the CalDAV/WebDAV spec itself, and (per the point above) Mail-in-a-Box's pinned Nextcloud version has no bundled app that adds one.

2. Microsoft Graph webhook change notifications for calendar events

  • Graph's /subscriptions API explicitly supports the event resource. The Outlook event resource is listed among subscribable resources with resource path /me/events / /users/{id}/events, supporting both delegated (personal Microsoft account, via Calendars.Read) and application permissions — confirming free Outlook.com/personal-account calendars are covered, consistent with §2 of the base stack research. — Create subscription, Microsoft Graph v1.0, learn.microsoft.com, Set up notifications for changes in resource data — Supported resources, learn.microsoft.com
  • Creating a subscription: POST https://graph.microsoft.com/v1.0/subscriptions with a JSON body containing changeType (created, updated, deleted, comma-separated as needed), notificationUrl (the app's public HTTPS webhook endpoint), resource (e.g. /me/events), expirationDateTime, and a required clientState secret the app later uses to confirm notifications genuinely came from Graph. A successful call returns 201 Created with the subscription object including its id. — Create subscription, Microsoft Graph v1.0, learn.microsoft.com
  • Validation-token handshake (mandatory, happens synchronously during subscription creation): Graph sends POST https://{notificationUrl}?validationToken={opaqueToken} with Content-Type: text/plain. The app's endpoint must URL-decode the token and respond within 10 seconds with HTTP 200 OK, Content-Type: text/plain, and a body containing exactly the plain-text (decoded) validation token — an HTML/JS-encoded response fails validation. If this handshake fails, Graph does not create the subscription (400 Bad Request) at all. — Receive change notifications through webhooks — notificationUrl validation, learn.microsoft.com
  • Notification payload shape delivered on change: a POST to notificationUrl carrying a changeNotificationCollection — a JSON object with a value array, where each entry has id, subscriptionId, subscriptionExpirationDateTime, clientState (must be checked against the value set at creation to reject spoofed notifications), changeType, resource (path to the changed item), tenantId, and a resourceData block (@odata.type, @odata.id, @odata.etag, id) identifying the changed resource — by default this is a basic notification (no event body/fields, just enough to know something changed and re-fetch it). A single POST may bundle multiple notifications across subscriptions. The app must return 2xx within 3 seconds (200 if processed inline, 202 if merely queued) or Graph begins retrying for up to 4 hours with exponential backoff before giving up. — Receive change notifications through webhooks, learn.microsoft.com
  • Maximum subscription duration for event (and message, contact) is under 7 days: 10,080 minutes. (Rich/resource-data subscriptions on these resources are capped much lower, at under 1 day — 1,440 minutes — but this project would use basic notifications and re-fetch via the API, so the 10,080-minute ceiling applies.) Graph subscriptions are explicitly not indefinite — the docs state apps "need to renew their subscriptions before the expiration time; Otherwise, they need to create a new subscription." — Set up notifications for changes in resource data — Subscription lifetime, learn.microsoft.com
  • Renewal: PATCH https://graph.microsoft.com/v1.0/subscriptions/{id} with a JSON body containing only the new expirationDateTime (must still respect the same per-resource max-duration ceiling from the point above — a single renewal cannot push the expiry further than ~7 days out from the renewal call). A successful renewal returns 200 OK with the updated subscription object. Each change notification also carries the current subscriptionExpirationDateTime, which the docs recommend using as a cue for when to renew. Optionally, a lifecycleNotificationUrl can be registered alongside notificationUrl to receive an explicit "subscription about to expire" / "reauthorization required" push rather than relying solely on tracking dates client-side. — Receive change notifications through webhooks — Subscription lifecycle / Renew a subscription, learn.microsoft.com

For issue #5 ("Cache refresh trigger mechanism"), use two different strategies per backend, since only one of the two sources has a usable push mechanism:

  • Nextcloud/Mail-in-a-Box (CalDAV): poll with WebDAV Collection Sync (RFC 6578 sync-token), not webhooks. Mail-in-a-Box's pinned Nextcloud (27.1.11) predates the webhook_listeners app (Nextcloud ≥30) entirely, so no webhook path exists on this backend without the household upgrading Nextcloud out-of-band from Mail-in-a-Box's own installer — not something to build a dependency on. Implement the Symfony CalDAV client to store each calendar's sync-token, issue a periodic sync-collection REPORT (e.g. on the cache-refresh job's normal cadence), and only re-parse/update the SQLite cache for the returned deltas rather than re-fetching the whole calendar every cycle.
  • Microsoft Graph: use webhook change notifications (/subscriptions on /me/events), since Graph explicitly supports it end-to-end for both personal and work/school accounts. Implementation shape:
    1. Symfony backend exposes a public HTTPS notificationUrl endpoint that (a) handles the synchronous validationToken handshake (echo it back as text/plain, 200 OK, within 10s) and (b) accepts subsequent POST change-notification payloads, checks clientState, and — since notifications are basic (no event data) — triggers a re-fetch of the changed event/calendar into the SQLite cache.
    2. Create the subscription with changeType: "created,updated,deleted", resource: "/me/events", and an expirationDateTime no more than ~7 days out (10,080-minute cap).
    3. Renewal is mandatory, not optional — schedule a recurring job (well inside the 7-day window, e.g. daily) that PATCHes the subscription with a fresh expirationDateTime; a lapsed subscription silently stops delivering notifications. Track subscriptionExpirationDateTime from incoming notifications, or subscribe to lifecycleNotificationUrl events, as a second signal so a missed renewal is self-detected rather than silently going stale.
    4. Keep a low-frequency polling fallback even with the webhook wired up — Graph notifications can be delayed, dropped after throttling, or silently lapse if renewal fails; a periodic full-resync (e.g. once daily) against /me/events guards against any single missed/expired subscription leaving the dashboard's cache stale indefinitely.