Snout Push: notifications to every device, sent from your database
Every SnoutData Cloud project can now send push notifications to iPhone, Android and the web. You send one from SQL or from one API, and the devices, the queue and the record of every delivery are tables in your own database, next to the rows that caused them.
create function notify_shipped() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
if new.status = 'shipped' and old.status <> 'shipped' then
perform push.send(
'{"title": "Your order shipped", "url": "/orders"}',
user_ids => array[new.user_id]
);
end if;
return new;
end $$;
create trigger orders_shipped after update on orders
for each row execute function notify_shipped();That is the whole integration on the sending side (security definer, so it may send whoever changed the row). The order ships, the row changes, and every device that user has registered gets the notification: an iPhone through Apple, an Android phone through Firebase Cloud Messaging, a browser through its own push service. No webhook to a second service, no copy of your user list kept somewhere else, no job queue to run.
Push belongs next to your data
An app that sends notifications usually ends up with its device list in one place, its users in another and the reason to notify them in a third, glued together by a function whose only job is to keep them in step. Deleting a user means remembering their devices live elsewhere. Asking "did that announcement reach anyone" means reading a dashboard you cannot join against.
Snout Push keeps all of it in the push schema of your project's Postgres:
push.devices: one row per installation, linked to your users, so a deleted user takes their devices along when auth is on.push.messages: the queue. Sending is an insert, run as the caller.push.deliveries: one row per device per message, with the provider's own answer: accepted, failed with its reason, or the token gone because the app was removed.push.topics: named audiences your users join and leave themselves.
So the questions you would ask about notifications are SQL you can already write:
select m.id, count(*) filter (where d.status = 'accepted') as accepted,
count(*) filter (where d.opened_at is not null) as opened
from push.messages m join push.deliveries d on d.message_id = m.id
group by m.id order by m.id desc limit 10;Your policies decide who may notify whom
Because a send is an insert into push.messages made as the caller, the access control is ordinary row-level security. With no policy, only your server's key can send. One policy lets a user notify the people in their own chats, and nobody else:
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 someone else: the row records who sent it, whatever the insert said.
Your keys stay in your project
To reach an iPhone you need an Apple push key, and to reach Android you need a Firebase service account from your own Firebase project. You upload them on the dashboard's Push tab or with snoutdata push credentials set. They are checked before they are kept, so a key that will not work is refused with the reason rather than failing every notification later.
Then they are stored in your project's own database, readable only by the push server's own role, and never shown again. The push server itself runs inside your project, beside your database, and serves nobody else. Our control plane passes a key through to your project once and keeps nothing.
Browsers need no key from you at all. Your project made its own Web Push key pair when push first started, so a web app gets notifications with no Firebase project involved.
From the app's side
// once the platform hands you a token
await db.push.register({ transport: 'apns', token })
// in a browser, after the user says yes
await db.push.subscribeWeb(await navigator.serviceWorker.ready)
// from your server
await db.push.send({ notification: { title: 'Welcome' }, userIds: [id] })That is @snoutdata/client 0.3.0. The same calls are plain HTTP at <ref>.api.snoutdata.com/push/v1 for any language.
One thing we are careful about: when Apple or Google accepts a notification, we record it as accepted, never "delivered". A phone that is switched off gets it later, or never, and a provider's acceptance cannot tell you which. A delivery counts as received or opened only when your app says so, with the id every notification carries.
What it costs
Push is on every plan, including free, with no price per notification and no monthly quota. How fast a project sends follows the size of its plan: a bigger plan sends more at once.
Sending at a later time is on the paid plans. A free project sleeps when it is idle, and a sleeping project has nothing to send at a time you chose, so a future send time is refused with a sentence saying so rather than held and silently missed. The first notification after a free project has slept waits for it to wake. Sending does not keep a project awake.
Small, and open source
The push server is one static Rust binary in a 4.7 MB container image. In a live project it used 4.6 MB of memory, measured with real work done: its encrypted connection to Apple's push service open and a signed notification sent through it. Every address it connects to is checked before it connects, its parsers have been fuzzed, and its Web Push encryption matches the standard's own worked example byte for byte.
It is Apache-2.0 at github.com/snoutdata/snout-push, so you can read exactly what runs beside your data, or run it yourself against your own Postgres.
Where it is today
Live on SnoutData Cloud for iPhone, iPad and Mac apps, Android apps and browsers. Not built yet: sending at a later time on the free plan, UnifiedPush, Expo's push tokens and Live Activities.
Switch it on from your project's Push tab at dashboard.snoutdata.com, and read the Push documentation for the keys, registering devices and the delivery log.