Skip to main content

Push notifications

Snout Push sends notifications to iPhone, iPad and Mac apps (through Apple's APNs), to Android apps (through Firebase Cloud Messaging), and to browsers (Web Push), from one API at https://<ref>.api.snoutdata.com/push/v1 and from SQL.

It runs inside your project, beside your database, and keeps everything there:

  • The devices, the queue and the log of every delivery are tables in the push schema of your database. You read them with SQL like any other table of yours.
  • Your keys are rows in your database too: your Apple key, your Firebase service account and your Web Push keys. SnoutData's own systems never store them.
  • Your row-level security decides who may notify whom. Sending is an insert into push.messages, run as the caller, so an ordinary Postgres policy is the whole of the access control. With no policy, only your server (the service_role key) can send.
FreePlus, Pro and Business
Push to iPhone, Android and the webyesyes
Send from SQL and from the APIyesyes
A send at a later time (send_at in the future)refused, with a sentence saying whyyes
Retries after a provider's errora few, within about five minuteson the provider's own schedule, for as long as it asks
A cap on how many notifications you sendnonenone

There is no per-notification price and no quota. How fast a project sends follows the size of its database's plan: a bigger plan sends more notifications at once.

Switching it on​

On the dashboard, open your project and choose Push, then Turn on push. Your database restarts once, which takes a few seconds, and push is running within about a minute.

The page then shows three cards: Web Push, which is ready at once, and APNs and FCM, which need your own keys.

Your keys​

Web Push needs nothing from you. Your project made its own key pair (VAPID) when push first started, and a browser subscribes with its public half. No Firebase project is involved.

iPhone, iPad and Mac apps need an APNs key from your Apple Developer account: under Certificates, Identifiers & Profiles, Keys, create a key with Apple Push Notifications service enabled and download its .p8 file. One key serves all your apps. On the APNs card enter your app's bundle id, the key id, your team id, and choose the file.

Android apps need your own Firebase project, because Google delivers to Android only through FCM with the credentials of the project the app is built against. In the Firebase console, under Project settings, Service accounts, choose Generate new private key, and upload that file on the FCM card.

Each key is checked before it is stored. An APNs key must be a valid key that signs; Apple itself sees it with the first notification you send, and a key Apple refuses shows as that delivery's error (APNs: InvalidProviderToken). A Firebase service account must get a real token from Google before it is accepted. A stored key is never shown again, only what identifies it.

From a terminal, snoutdata push credentials set does the same (CLI 0.6.0 and later). From a server, it is PUT /push/v1/credentials/apns or /fcm with the service_role key:

curl -X PUT "https://<ref>.api.snoutdata.com/push/v1/credentials/apns" \
-H "apikey: <your service_role key>" \
-H "Content-Type: application/json" \
-d '{"topic": "com.example.app", "keys": [{"p8": "-----BEGIN PRIVATE KEY-----\n...", "key_id": "ABC123DEFG", "team_id": "TEAM123456"}]}'

The keys live in push.credentials, which only the push server's own role reads: not the service_role key and not your users, and the project's owner only by deliberately switching to that role. GET /push/v1/credentials says what is set, without the secrets.

Registering a device​

A device registers for the user who is signed in, with that user's access token from Authentication. Your app gets a token from the platform first, then hands it over:

curl -X POST "https://<ref>.api.snoutdata.com/push/v1/devices" \
-H "apikey: <your anon key>" \
-H "Authorization: Bearer <the user's access token>" \
-H "Content-Type: application/json" \
-d '{"transport": "apns", "token": "<the device token, hex>", "environment": "production"}'
transporttokenAlso
apnsthe device token your iOS app received, as hexenvironment: production (the default) or sandbox for a development build; app: the bundle id, when one project serves several apps
fcmthe FCM registration token your Android app receivedapp, when one project serves several apps
webthe subscription's endpointp256dh and auth: the subscription's two keys

Registering the same token again updates the same row. If a phone changes hands and the new user registers its token, it stops notifying the previous user (set push.settings.shared_devices to keep both, for an app with account switching). DELETE /push/v1/devices/<id> removes one, on sign-out for instance.

A browser subscribes with your project's public key, then registers the subscription. In the page, after the user has granted permission:

const base = 'https://<ref>.api.snoutdata.com/push/v1'
const headers = { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }

const { key } = await (await fetch(`${base}/vapid-public-key`, { headers })).json()
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: Uint8Array.from(atob(key.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)),
})
const { keys } = subscription.toJSON()
await fetch(`${base}/devices`, {
method: 'POST',
headers,
body: JSON.stringify({ transport: 'web', token: subscription.endpoint, p256dh: keys.p256dh, auth: keys.auth }),
})

and in the service worker, show what arrives (every browser requires a push to show something):

self.addEventListener('push', (event) => {
const { title = '', body, image, data = {}, url } = event.data?.json() ?? {}
event.waitUntil(self.registration.showNotification(title, { body, image, data: { ...data, url } }))
})

A page whose visitors are not signed in can let them subscribe anonymously: as the project's owner, update push.settings set anonymous_devices = true, and register with the anon key alone.

Sending​

From SQL, in a trigger, a function or the SQL editor:

select push.send('{"title": "Your order shipped", "body": "It arrives Thursday."}',
user_ids => array['3f1c...'::uuid]);

From a server, with the service_role key:

curl -X POST "https://<ref>.api.snoutdata.com/push/v1/send" \
-H "apikey: <your service_role key>" \
-H "Content-Type: application/json" \
-d '{"notification": {"title": "Your order shipped"}, "user_ids": ["3f1c..."]}'

Either returns the message's id. A message has exactly one target: user_ids (every device of those users), topic, or device_ids. It also takes send_at (paid plans), ttl (seconds a provider may hold it for a device that is offline), priority (high or normal) and collapse_key (a newer message with the same key replaces an undelivered older one).

A notification is title, body, data (delivered to your app beside it), badge, sound, thread (groups notifications on the device), image, url (where a click on a web notification goes) and background (shows nothing and wakes the app to handle data; never sent to a browser). For anything a platform has that this shape does not name, apns, fcm and web objects are merged over what is built for each. An unknown field is refused, not ignored.

Letting users send. With no policy only service_role sends. A policy on push.messages opens exactly what you mean, for example letting a user notify the other members of their chats:

create policy "notify my chats" on push.messages for insert to authenticated
with check (target_topic in (select 'chat:' || chat_id from chat_members where user_id = auth.uid()));

A sender cannot pretend to be somebody else: created_by is always the caller.

Topics​

A topic is a named audience. Make one as the project's owner or with service_role:

insert into push.topics (name, description) values ('news', 'Product news');

A signed-in user joins and leaves for themselves with PUT and DELETE /push/v1/topics/news/members, and a message sent with "topic": "news" reaches every device of every member.

What happened to a notification​

Every message is a row in push.messages and every device it went to is a row in push.deliveries, with the provider's own answer:

push.deliveries.statusMeaning
pendingnot sent yet, or waiting for a retry
acceptedthe provider accepted it. This is not "delivered": a phone that is off gets it later, or never
failednot delivered to the provider: your key was refused, or the retries ran out. error is the provider's reason
unregisteredthe device's token is no longer valid (the app was removed), so the device is switched off
refusednever sent, because this notification cannot go to this device as it stands (too large once built for it, or a silent notification to a browser). error says which

received_at and opened_at are filled in only when your app reports them. Every notification's data carries snout_push_delivery, the delivery's id; the app sends it back with POST /push/v1/receipts and {"delivery_id": <id>, "event": "received"} (or "opened"). A provider's acceptance is never counted as a delivery.

A signed-in user sees the deliveries to their own devices and the messages they sent. The project's owner, in the SQL editor or any Postgres client, sees everything:

select m.id, m.status, d.status, d.error, d.accepted_at, d.received_at
from push.messages m join push.deliveries d on d.message_id = m.id
order by m.id desc limit 20;

Finished messages and their deliveries are kept for 30 days, and a device not seen for 30 days is switched off; both are in push.settings.

With @snoutdata/client​

Since 0.3.0, @snoutdata/client does all of the above as db.push:

import { createClient, deliveryIdOf, webNotification } from '@snoutdata/client'

const db = createClient('https://<ref>.api.snoutdata.com', '<your anon key>')

// In an app, once the platform has given you a token (signed in as the user):
await db.push.register({ transport: 'apns', token, environment: 'production' })
// In a browser, with your service worker's registration, after permission is granted:
await db.push.subscribeWeb(await navigator.serviceWorker.ready)
// A topic, for the signed-in user:
await db.push.join('news')
// On a server, with the service_role key (or as a user your policies allow):
const { data } = await db.push.send({ notification: { title: 'Your order shipped' }, userIds: [userId] })
// When a notification arrives, report it:
await db.push.receipt(deliveryIdOf(payload), 'received')

and in the service worker, webNotification turns what arrives into showNotification's arguments:

self.addEventListener('push', (event) => {
const { title, options } = webNotification(event.data?.json())
event.waitUntil(self.registration.showNotification(title, options))
})

Limits, and what is not built​

  • A later send is paid. On the free plan your database pauses when it is idle, and a paused project has nothing to send at a time you chose. A future send_at is refused with a sentence about the plan (the API answers with it; a row inserted from SQL is marked refused with it), never silently held.
  • The first notification after a pause waits for the wake, about a second when the project was paused recently and 10 to 20 seconds when it was paused long ago. Sending does not keep a project awake.
  • A deleted user's devices go with them only when Authentication is on. When auth is switched on after push, this starts within the hour.
  • Not built yet: UnifiedPush, Expo's push tokens, Live Activities, and push in the MCP server's set_product (use snoutdata products enable push).