Push Notifications
A push notification is a queue message with a different destination. Your app registers its device token, you enable push under Queues → Notifications — it signs with the Apple key you already use for sign-in — and a trigger on a queue turns every message that names a user into a notification on each of that user's devices.
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: enable push, send a test push, see registered devices. Credentials live under Authentication → Providers |
| CLI | None — configure in the portal; send by putting a message on a queue |
| SDK / HTTP | shovelbase.push.register(deviceToken:) (Swift). Sending is server-side: shovelbase.queues.send() or POST /queues/v1/<queue> |
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 shovelbase 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 shovelbase 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 is nothing to paste. Push signs with the Apple key already stored for Sign in with Apple, so Queues → Notifications shows that team ID, key ID and bundle ID read-only and gives you one button to enable push. Change them — or add them, if you haven't set up Apple sign-in yet — under Authentication → Providers, where they have their single home.
Then use the test send box on the same page: paste a device token, pick the environment, and it reports Apple's raw status and reason. Do this before wiring up a queue — it is the difference between diagnosing push and guessing at it.
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 shovelbase. 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 Shovelbase // 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 shovelbase.push.register(deviceToken: token) }} // On sign-out:try await shovelbase.push.unregister()If someone is signed in, the SDK attaches their access token and the server binds the device to their auth.users.id — the same id auth.uid() returns in your RLS policies. 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 with your anon key, so a platform without the Swift SDK can register the same way. Device rows live in your project's own database, in a push schema that is not exposed through the REST API — a leaked anon key cannot enumerate your users' device tokens, though your own SQL and edge functions can read them.
Sending one
Create a queue, add a trigger on it with the notification service as its destination, and put JSON on the queue. 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. |
The most common producer is an edge function reacting to something in your database. It already has the service key, so enqueueing is one fetch — see Sending a message.
What is stored, and what is not
| Thing | Where it lives | How it goes away |
|---|---|---|
| The queued message | SQS | Deleted as soon as it is delivered. Anything undeliverable ages out on its own at the queue's retention period (4 days by default). |
| Device tokens | the push schema in your project database | 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.