Push Notifications
Push is built so the flow is yours and the delivery machinery isn't. Declare a notification service in project.toml — it signs with the Apple key you already use for sign-in — and send two ways: directly from a function with the push capability, or by putting a message naming a user on a queue whose trigger has the service as a destination. Your app registers its device token once; finding a person's devices, signing to Apple, retrying, and retiring dead tokens are all automatic.
The hard parts of push are not the sending. They are knowing which devices belong to a person, keeping that list clean as people reinstall and switch phones, holding an HTTP/2 connection to Apple with a signed token that expires every hour, and retrying without notifying somebody twice.
Modelling it as a queue destination gets all of that from machinery that already exists: the queue holds the message until it is delivered, the trigger reports what happened, and a device that Apple reports as gone is retired automatically. What you write is the one line that puts a message on the queue.
- Telling a user something finished
- A booking confirmed, a render completed, an order shipped. The function that already reacts to the change enqueues one message; the user's phone lights up whether the app is open or not.
- Reacting to a database change
- A row lands, a function sees it, and the person who cares hears about it — without your backend holding a connection to Apple or knowing which devices that person has.
- Silent background refresh
- A message with
dataand nonotificationbecomes a background push: nothing is shown, but the app is woken to fetch what changed.
| Surface | Availability |
|---|---|
| Portal UI | Queues → Notifications: declared services, registered devices, and a test send. Apple credentials live with the provider declaration — see Authentication |
| CLI | snoozestack notifications list/push/enable/test/devices/register-device |
| SDK / HTTP | snoozestack.push.register(deviceToken:) (Swift). Sending is server-side: capabilities.push.send() in a function, or a queue message |
What to do at Apple, once#
Two things in the developer portal and one in Xcode. If you already use Sign in with Apple, all three edit what you already have — no new key, no new App ID.
- Identifiers → your App ID → tick Push Notifications → Save. It must be an explicit App ID (
com.example.myapp); wildcard IDs cannot have push. Enabling the capability invalidates existing provisioning profiles — Xcode's automatic signing regenerates them on the next build. - Ignore the certificate offer. Ticking the box makes Apple offer to create Development and Production SSL Certificates. Skip both. Those belong to the older certificate-based authentication, which snoozestack does not use; they expire every 12 months, per app, per environment, and nothing here needs them.
- Keys → your Apple key → Edit → tick Apple Push Notifications service (APNs) → Save. The same key can carry both capabilities, so the one already signing your Sign in with Apple tokens is the one to use. If you don't have a key yet, create one with both ticked and download the
.p8— it downloads once. - In Xcode: target → Signing & Capabilities → + Capability → Push Notifications. This writes the entitlement that makes
registerForRemoteNotifications()return a token. Add Background Modes → Remote notifications too if you want silent pushes.
Editing an existing key does not change the key or invalidate the .p8 you already have. Which services a key may sign for is a record on Apple's side, keyed by the Key ID; the private key itself carries none of it. That is why Apple lets you edit a key it will never let you download again — and why the copy snoozestack already holds starts working for push within a few minutes of you ticking the box.
It also keeps you inside Apple's limit of two auth keys per team, which is the constraint worth designing around: keys are scarce, and one key doing both jobs spends none of that budget.
Turning it on#
There are no new credentials to paste — push signs with the Apple key your project already declares for Sign in with Apple (see Authentication); both the key and its App ID just need the APNs capability ticked at Apple. Declare the service and publish:
[[push]]name = "apple" # what a queue trigger's --notify namesprovider = "apns"Then send a test push before wiring anything else up — it reports Apple's raw status and reason, which is the difference between diagnosing push and guessing at it:
snoozestack notifications test --service apple --device <id> --title "Hi" --body "It works"Sandbox and production#
Apple runs two entirely separate push systems, and which one a device belongs to is decided when the app is built:
| How the app got on the phone | APNs world |
|---|---|
| You hit ⌘R in Xcode | sandbox |
| TestFlight or the App Store | production |
A token from an Xcode build only works against sandbox; a TestFlight token only against production. Both are 64 hex characters and there is no way to tell them apart by eye — sending to the wrong one returns BadDeviceToken, which looks exactly like a corrupt token. This is the single most common cause of “push doesn't work”.
You do not have to manage it. One .p8 signs for both worlds, the SDK detects which one the build belongs to and records it when the device registers, and each send is routed to the matching Apple server. A debug build on your phone and an App Store install on your iPad both work, at the same time, with no configuration.
Registering a device#
Ask for permission when it makes sense in your app — the SDK deliberately does not do this for you — then hand the token to snoozestack. Call it on every launch: tokens change on reinstall and on restore-to-a-new-device. A repeat registration of an unchanged token costs no request.
import Snoozestack // Ask for permission wherever it belongs in your onboarding, then:UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])UIApplication.shared.registerForRemoteNotifications() // must be on the main thread func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) { Task { try? await snoozestack.push.register(deviceToken: token) }} // On sign-out:try await snoozestack.push.unregister()If someone is signed in, the SDK attaches their session and the server binds the device to their user id — the same id a function sees as capabilities.auth.user.id. You never pass a user id yourself. A device that registers before anyone signs in is kept unattached, and the next launch binds it.
Under the hood this is a POST /push/v1/devices on the project host, so a platform without the Swift SDK can register the same way. The device registry is managed server-side and isn't client-readable, so nothing a shipped app holds can enumerate your users' device tokens. During development, snoozestack notifications register-device registers a real device token against the sandbox environment so a function running under snoozestack dev can deliver an actual notification — with no device registered, capabilities.push.send() reports a dry run (“would have sent to N devices”) instead of failing.
Sending one#
The direct path is one call in a function that declares the push capability:
await capabilities.push.send({ to: { user_id: user.id }, notification: { title: "Tee time confirmed", body: "Saturday, 9:40am" }, data: { booking_id: "42" },});For durability and fan-out — or to notify from something that isn't a function — put the same JSON on a queue whose trigger names the notification service (snoozestack triggers create … --notify apple). Either way, everything else — finding the person's devices, signing the request to Apple, retrying, retiring dead tokens — is automatic.
{ "to": { "user_id": "8f14e45f-ceea-467a-9575-3f0b1c2d4e5a" }, "notification": { "title": "Tee time confirmed", "body": "Saturday, 9:40am" }, "data": { "booking_id": "42" }}to accepts user_id, user_ids (several people), tokens (explicit device tokens, for testing), or distinct_id (the Signals identity, for devices with nobody signed in). Adding "environment": "sandbox" inside to narrows a send to your own debug devices — handy for trying a payload shape without pushing to real users.
| Field | Meaning |
|---|---|
notification | Title, subtitle, body, badge, sound. Omit it entirely (with data present) for a silent background push. |
data | Your own key/values, delivered to the app alongside the notification. |
collapse_id | Replaces any undelivered notification with the same id, instead of stacking. |
priority | 10 (immediate, the default for alerts) or 5. Background pushes are always 5 — Apple rejects them at 10. |
expiration | Unix time after which Apple stops trying; 0 means one attempt only. |
What is stored, and what is not#
| Thing | Where it lives | How it goes away |
|---|---|---|
| The queued message | the queue | Deleted as soon as it is delivered; anything undeliverable dead-letters or ages out on its own. |
| Device tokens | the managed device registry | Retired automatically when Apple reports the app was uninstalled. No cron job, no maintenance. |
| The notification content | nowhere | It is not kept. Once sent, it is gone. |
That last row is deliberate: there is no per-user notification history, no “resend”, and no in-app inbox. If you want one, write the notification to your own table when you enqueue it — you are the only one who knows what it should look like afterwards. Note that Apple gives no delivery receipt in any case: the strongest claim available anywhere is “accepted by APNs”, never “shown to the user”.
When something fails#
The trigger records the outcome of its last run, and the Triggers tab shows it —2/3 delivered plus Apple's reason for each failure. The common ones:
| Reason | What it means |
|---|---|
BadDeviceToken | Almost always the wrong environment (see above). The device is retired; re-registering from the app brings it back. |
Unregistered | The app was deleted from that device. Retired automatically. |
DeviceTokenNotForTopic | The bundle ID on the service does not match the app the token came from. |
InvalidProviderToken | The team ID, key ID or .p8 don't agree — or the key doesn't have the APNs capability ticked. |
A message is only left on the queue to retry when nothing got through at all. If one device in a fan-out of hundreds hits a transient Apple error, the message is not redelivered to all of them — being notified twice is worse than the one device missing out.