# Push Notifications

> Send iOS push notifications from a queue: register devices from the Swift SDK, enable APNs on your existing Apple key, and put a message naming a user on a queue.

[HTML version](https://snoozestack.com/docs/push-notifications/) · [Docs index](https://snoozestack.com/llms.txt)

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](https://snoozestack.com/docs/functions.md) with the `push` capability, or by putting a message naming a user on a [queue](https://snoozestack.com/docs/queues.md) 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.

| Surface | Availability |
| --- | --- |
| Portal UI | Queues → Notifications: declared services, registered devices, and a test send. Apple credentials live with the provider declaration — see [Authentication](https://snoozestack.com/docs/authentication.md) |
| 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 |

### Locally

Under `snoozestack dev` the device registry is real and local: an app pointed at the dev server registers its token at `/push/v1/devices` (with the local anon key) and the Notifications page lists it. A function's `capabilities.push.send()` resolves the target against that registry and records the send — devices matched, payload as sent — to `.snoozestack/outbox/push/` and the same page, without contacting Apple; the result carries `recorded` instead of a delivery. When a real device must buzz, start with `snoozestack dev --push=sandbox` (or `[dev] push = "sandbox"` in `snoozestack/dev.toml`) and sends take the hosted sandbox gateway, which needs the directory linked and logged in.

### 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.
1. **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.
1. **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*.
1. **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](https://snoozestack.com/docs/authentication.md)); both the key and its App ID just need the APNs capability ticked at Apple. Declare the service and publish:

```bash title="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:

```bash title="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 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.

```swift title="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](https://snoozestack.com/docs/permissions.md) 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:

```bash title="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.

```bash title="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](https://snoozestack.com/docs/signals.md) 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 — the Triggers card on the console's Queues page and `snoozestack triggers list` both show it, as `2/3 delivered` plus Apple's reason for each failure — and a test send from the Notifications page reports the same raw status. 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.
