Shipping one of the first AlarmKit apps: what the docs don't tell you
iOS 26 added AlarmKit, the first public API that lets a third-party app schedule an alarm with the same standing as the Clock app: full-screen, audible through Silent mode and Focus, ringing until the user stops it. We used it to ship KeyAlarm, a small app that turns calendar events into real alarms. It went live on August 27, 2026, a couple of weeks after the API did.
There is very little written about AlarmKit in production yet. These are the things we wish we had known.
1. There is no AlarmKit entitlement
This one cost us a day. While writing the scheduler we ended up with com.apple.developer.alarmkit: true in the entitlements file. The simulator ran fine. The first device build failed with "Provisioning profile doesn't include the com.apple.developer.alarmkit entitlement", and the Developer Portal had no AlarmKit capability to enable.
That entitlement does not exist. It was invented by an LLM that conflated an Info.plist usage-description key with a capability. Apple Developer Forums thread 797950 has an Apple engineer describing exactly this pattern: LLMs producing entitlements that were never real, and developers unable to tell "declared entitlement", "entitlement you must apply for", and "Info.plist key" apart.
What AlarmKit actually needs:
NSAlarmKitUsageDescriptionin Info.plist (and note:xcodebuild -exportLocalizationsdoes not pick this key up, so localize it by hand in eachInfoPlist.strings)AlarmManager.shared.requestAuthorization()at runtime
Delete the fake entitlement and the device build goes through.
2. Alarm identity must be deterministic
AlarmKit alarms have a UUID you choose. We derive it from exactly two things: the calendar event identifier and the fire time truncated to the minute.
// PlannedAlarm.deterministicID — UUIDv5-style, seconds dropped so EventKit's
// millisecond jitter on start dates never changes the ID.
static func deterministicID(eventID: String, fireDate: Date) -> UUID {
let fireMinute = Int(fireDate.timeIntervalSince1970.rounded(.down)) / 60
return uuidV5(name: "\(eventID)|\(fireMinute)")
}
Nothing else goes in. Not the rule that matched, not the event title, not the lead time in minutes. The reason is what happens on re-scan: if the ID changes, you have to cancel the old alarm and schedule a new one, and if that cancel/schedule pair straddles the fire time, the alarm silently disappears. With a stable ID, re-scan becomes an idempotent reconcile: compute the desired set, diff against AlarmManager.shared.alarms, add what is missing, cancel what is gone, touch nothing else.
Sound, snooze and countdown are deliberately not part of the ID either. They go into a separate "config fingerprint" that the reconciler compares to decide whether an existing alarm needs re-registering. One subtlety: the countdown value shrinks naturally as the fire time approaches, so the fingerprint only records whether a countdown exists, not its length. Comparing the value made every sync during a countdown look like a config change, which cancelled and re-created the alarm while its Live Activity was on screen.
Related rule: never cancel an alarm that has already fired. We hit a bug where relaunching the app while an alarm was ringing ran the startup sync, which "reconciled away" the ringing alarm. The reconciler now skips cancels for alarms whose fire time is in the past.
Two test suites guard all of this and we treat them as untouchable: one asserts the ID is stable across rule edits and title changes, the other asserts that running the sync twice produces zero AlarmKit calls the second time.
3. Don't depend on background refresh. Pre-register two weeks ahead.
BGAppRefreshTask is best-effort. On a phone that sits in Low Power Mode it may not run for days. If your alarms are created by a background job, the failure mode is "nothing rang and nobody noticed".
So we don't. Whenever the app is opened, or the calendar store changes, or a rule is edited, we register every alarm for the next 14 days directly with AlarmKit. Once registered, they fire whether or not the app ever runs again. Background refresh is a bonus, not a dependency.
The cost is the concurrent alarm budget. AlarmKit's limit is not documented. We cap at 30 by default, prioritize the soonest, and show the user how many were dropped rather than dropping silently.
4. Keep the planner pure
Three files do all the thinking: KeywordMatcher, AlarmPlanner, Reconciler. None of them import EventKit or AlarmKit. The OS boundary is two protocols, EventProviding and AlarmScheduling, with fakes for tests. Everything in section 2 and 3 is unit-tested against those fakes, including the "second run is a no-op" property. If we had let EKEvent or AlarmManager leak into the planner, none of it would be testable without a device.
5. Live Activity metadata has to live in a shared target
AlarmKit shows a countdown as a Live Activity, typed with your own AlarmAttributes<Metadata>. The Metadata type must be byte-for-byte identical in the app and the widget extension. If the file is in one target only, the Live Activity fails to decode and you get a blank countdown with no error. We put KeyAlarmMetadata.swift in a Shared/ folder that both targets list in sources.
6. Any remote kill switch must fail open
We have a tiny version.json on our website so we can force an update if a release turns out to have a bug that stops alarms from firing. The check returns .ok on any network failure. Fail-closed would mean airplane mode, a captive Wi-Fi portal, or our CDN having a bad day turns an alarm app into a brick. There is a test for the fail-open path and it is not optional.
7. .timeSensitive silently downgrades without its entitlement
Unrelated to AlarmKit but in the same app: we send a plain UNNotification when a scan finds zero upcoming alarms. Setting interruptionLevel = .timeSensitive without the Time Sensitive Notifications entitlement does not error. It quietly becomes .active. That one is a real entitlement, which is part of why the AlarmKit confusion is so easy to fall into.
8. Review: guideline 5.1.1(iv)
We have a pre-permission screen that explains why the app needs the calendar and AlarmKit before the system prompts appear. The first submission was rejected under 5.1.1(iv) because the button on that screen said "Allow". A custom screen must not look like it is granting the permission itself; the button has to be neutral. Changing it to "Continue" was the entire fix. Cheap to get right the first time.
What KeyAlarm does with all this
Register a keyword once ("Piano"). Every calendar event whose title contains it becomes a real alarm at one or more lead times (30 minutes before to get ready, 5 minutes before to leave). No server, no account, App Store privacy label "Data Not Collected". iOS 26 and up, 30-day trial, then a one-time purchase.
- App Store: apps.apple.com/app/id6801792687
- Product page: ltng.jp/apps/keyalarm/en
Questions about AlarmKit are welcome through the contact form.