snoozestackdocs

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.

Why use it

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 data and no notification becomes a background push: nothing is shown, but the app is woken to fetch what changed.
SurfaceAvailability
Portal UIQueues → Notifications: declared services, registered devices, and a test send. Apple credentials live with the provider declaration — see Authentication
CLIsnoozestack notifications list/push/enable/test/devices/register-device
SDK / HTTPsnoozestack.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.

  1. 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.
  2. 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.
  3. 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.
  4. 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:

snoozestack/project.toml
[[push]]
name = "apple" # what a queue trigger's --notify names
provider = "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:

terminal
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 phoneAPNs world
You hit ⌘R in Xcodesandbox
TestFlight or the App Storeproduction

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.

AppDelegate.swift
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:

inside a function
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.

the message
{
"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.

FieldMeaning
notificationTitle, subtitle, body, badge, sound. Omit it entirely (with data present) for a silent background push.
dataYour own key/values, delivered to the app alongside the notification.
collapse_idReplaces any undelivered notification with the same id, instead of stacking.
priority10 (immediate, the default for alerts) or 5. Background pushes are always 5 — Apple rejects them at 10.
expirationUnix time after which Apple stops trying; 0 means one attempt only.

What is stored, and what is not#

ThingWhere it livesHow it goes away
The queued messagethe queueDeleted as soon as it is delivered; anything undeliverable dead-letters or ages out on its own.
Device tokensthe managed device registryRetired automatically when Apple reports the app was uninstalled. No cron job, no maintenance.
The notification contentnowhereIt 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:

ReasonWhat it means
BadDeviceTokenAlmost always the wrong environment (see above). The device is retired; re-registering from the app brings it back.
UnregisteredThe app was deleted from that device. Retired automatically.
DeviceTokenNotForTopicThe bundle ID on the service does not match the app the token came from.
InvalidProviderTokenThe 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.