Field note · 5 min read

How we built calendar push notifications without a Google account

Calendar push on iOS and Android usually means a round-trip through Google Calendar's webhook plumbing. We skipped that and built it natively so multi-business operators don't have to sign 3+ brands into 3+ Google accounts just to get a reminder.

Calendar push notifications on Emcognito WebMail do not pass through Google Calendar. There is no OAuth hop, no Apple iCloud account, no Microsoft Graph subscription. The phone rings because a DynamoDB row fired, an async loop noticed, and we wrote bytes directly to APNs and FCM. Here is how the build went and why we did not just bounce reminders through someone else's calendar.

The shape of the problem

The product wedge for Emcognito is multi-business operators: one human, three to ten brands, one inbox per brand. Calendar push as designed by Google and Apple assumes the opposite. One user, one Google account, one push channel. The second you operate three brands you are either logging in and out of Google all day or you are leaking one brand's identity into another brand's confirmation email. Neither is acceptable.

So we needed reminders that fired from our backend, addressed to our device tokens, scoped to our event identities. No third-party calendar required.

The NOTIFEV# row pattern

Emcognito's calendar lives in a single-table DynamoDB design. Events are stored under a partition key per user with a sort key like EVENT#{eventId}. Reminders are sibling rows under the same partition, with sort key NOTIFEV#{eventId}#{occurrenceStartIso}#{offsetMin}.

That sort key shape is load-bearing. It means:

  • Idempotency is free. Writing the same reminder twice is a no-op because the composite key is deterministic from (eventId, occurrence-start, offsetMin).
  • Recurring events expand lazily. We do not materialize 200 NOTIFEV# rows for a weekly meeting; we materialize the next N occurrences on a rolling window and refill as they fire.
  • Cancellation is a single delete on a known key. When a user cancels a single occurrence of a recurring event, only that one NOTIFEV# row dies.

Each row carries the minimum payload to fire: userId, eventId, occurrenceStartIso, reminderOffsetMin, fireAtIso, and a fired boolean. That is it. The event title and location are looked up from the EVENT# row at fire time so an edit to the event copy is reflected in the push body without rewriting reminders.

The fire loop

A scheduler runs every 5 minutes. It scans rows where fireAt falls within now ± 5 minutes and fired = false. The ±5 window is the slack that lets us survive a missed tick without double-firing: each row is conditionally updated to fired = true with a ConditionExpression on the prior state, and only the writer that wins the CAS actually sends the push.

The loop is intentionally boring. No SQS, no EventBridge schedule per reminder, no Step Functions. A 5-minute cadence over a small, well-indexed slice of rows is cheap and survives every failure mode we have hit in production. We tried per-reminder EventBridge schedules first and the control-plane quota became the bottleneck around 1,500 active users.

The APNs payload

The iOS payload looks like this:

{
  "aps": {
    "alert": { "title": "Standup with Acme", "body": "in 10 minutes · Zoom" },
    "sound": "default",
    "category": "CALENDAR",
    "thread-id": "cal-{eventId}"
  },
  "calendar": {
    "eventId": "evt_01HW...",
    "identity": "alice@acme.com",
    "startsAt": "2026-05-24T14:00:00Z",
    "kind": "reminder"
  }
}

Two details that matter. aps.category=CALENDAR gives the user a notification action set we register at app launch (Snooze 10m, View, Dismiss). thread-id collapses repeated reminders for the same event into a single visual stack on the lock screen, which matters a lot when you have a 1-hour and a 10-minute reminder on the same meeting.

The custom calendar block carries the identity the notification was fired for. Multi-business operators have multiple identities in one app; the tap handler routes into the right brand's inbox view, not a generic "calendar" tab.

The FCM mirror

Android is structurally the same with one quirk. We send both a notification block (so the OS can render even if our app is in the background and we never get a chance to handle the data payload) and a data block (so a foregrounded app can route on the same fields the iOS app does). We pin android.priority="high" for reminders inside 15 minutes of fire and "normal" for the longer-lead reminders, which keeps us inside Google's heuristics for what gets to bypass Doze.

Why not just use Google Calendar's webhooks?

This is the part that decides the architecture. Google's calendar push works by you OAuthing into a Google account, subscribing to that account's calendar with a webhook URL, and getting a poke when anything changes. Then you derive what to render and when.

For a one-account-per-user product that is fine. For a multi-business operator running five brands, you would have to:

  1. Convince the user to OAuth five separate Google accounts into your app.
  2. Manage five sets of refresh tokens and renew five sets of channel subscriptions every seven days (Google's webhook channels expire).
  3. Re-derive reminder fire times from the change-feed yourself anyway, because Google does not push you a "send a reminder now" event; it pushes you a "something changed, go re-read."
  4. Accept that your reminder reliability is now a function of Google's webhook delivery, which is best-effort.

At step 1 the wedge breaks. The whole reason a user is on Emcognito is to not be juggling five Google logins. So we built it ourselves, and the build turned out to be smaller than the integration would have been.

What we got out of it

End-to-end, the latency from fire-time to lock-screen is dominated by APNs and FCM delivery rather than our own loop. Reminders are designed to fire within about a minute of their scheduled time, and because each reminder is an idempotent row keyed by event, occurrence, and offset, a restarted or double-running loop re-fires cleanly instead of dropping or duplicating a notification. The whole reminder service is about 600 lines of Python.

And the user does not see any of it. They see a notification.

FAQ

How do calendar push notifications work without Google Calendar?

An event is stored in the app's own database with a separate reminder row for each fire time. A background loop runs every few minutes, finds reminders whose fire time has arrived, and sends a push notification directly to the device using Apple Push Notification service (APNs) on iOS and Firebase Cloud Messaging (FCM) on Android. The user's Google account is never touched.

Why does Emcognito not use Google Calendar's webhook channels for reminders?

Google Calendar's webhook channels are designed for one Google account per user. Emcognito is built for operators who run multiple brands and have multiple identities, and asking them to OAuth three to ten Google accounts to receive reminders defeats the product's purpose. A native APNs/FCM path also gives us tighter control over reminder reliability and payload contents.

What is the NOTIFEV# row pattern in DynamoDB?

NOTIFEV# is the sort-key prefix Emcognito uses for reminder rows in its single-table DynamoDB design. Each row's sort key includes the event ID, the occurrence start time, and the reminder offset in minutes, which makes reminders idempotent on rewrite and trivially cancellable on a per-occurrence basis.

Is APNs or FCM more reliable for calendar reminders?

Both are reliable enough that the dominant source of error is stale device tokens, not delivery itself. The reminder loop targets sub-minute fire accuracy on both platforms, and keeping device tokens fresh is the main operational concern.


Related reading. Calendly vs. Emcognito booking links: the per-seat math when you run multiple brands and Subscribe to Google Calendar without giving us OAuth scopes.

See the feature in action at /calendar or read about the multi-business case at /for/multi-business.

§ Sources & further reading