Push notifications
Send a notification to a user's phone from Go, in one line. The API talks to Expo's push service, which relays to Apple and Google, so there is no APNs certificate or Firebase key to manage.
grit plugin add pushgrit migratepnpm install
Send one
Go sends in the background, so the request that caused it never waits on the push service. Every device the users registered gets it.
push := services.NewPush(db)push.Go([]string{order.CustomerID}, services.PushMessage{Title: "Your order shipped",Body: "Order " + order.Number + " is on its way.",Sound: "default",// Arrives with the notification: what the app opens when it is tapped.Data: map[string]string{"order_id": order.ID},})
Send does the same and waits, returning how many devices Expo accepted: for a background job that wants the result.
In the Expo app
apps/expo/lib/push.ts asks for permission, gets the device's token and registers it. Call it after sign-in and on every launch while signed in; unregister before signing out.
import { onNotificationTap, registerForPush, unregisterPush } from "@/lib/push";useEffect(() => {if (user) void registerForPush();}, [user?.id]);// The tap that opened the app counts too.useEffect(() => onNotificationTap((data) => {if (data.order_id) router.push(`/orders/${data.order_id}`);}), []);// In logout, before the session ends:await unregisterPush();
What you get
| Endpoint | Does |
|---|---|
| POST /api/v1/push/tokens | Registers the signed-in device. Anything but an Expo push token is refused. |
| POST /api/v1/push/tokens/remove | Unregisters it, if it is yours. The answer is the same either way. |
| POST /api/v1/push/test | Sends yourself a test notification, to check the set-up end to end. |
The details that matter
- Dead tokens clean themselves up. When an app is deleted or notifications are turned off, Expo answers
DeviceNotRegisteredfor that token, and it is removed on the spot instead of being tried with every message from then on. - A phone belongs to whoever signed in last. A token is unique, and registering it moves it to the current user, so a shared or handed-down phone stops getting the previous person's notifications.
- Batches of 100. Expo takes at most 100 messages a request; sending to more users makes as many requests as it needs.
- Ten devices a user. The oldest are dropped first, since a phone that was replaced stops re-registering.
- Real devices only. Simulators and the web cannot receive push;
registerForPushreturnsnullthere and the app carries on. Store builds need an EAS project id; setEXPO_ACCESS_TOKENif the project uses enhanced push security.
Checked against Expo's live service while building the WhatsApp blueprint: a message to someone with a registered token went out through Expo, and a token no device owned came back as not registered and was removed.
Using it, step by step
Sending a notification to a phone running the project's Expo app. Expo relays to APNs and FCM, so no certificates or Firebase keys go into the API.
Install it
grit plugin add push
Writes the token model and handlers, the services.Push sender, and apps/expo/lib/push.ts. It needs an Expo app in the project.
Run the migration
cd apps/api && go run cmd/migrate/main.go
Device tokens are rows against a user, so a phone that signs in as somebody else stops receiving the last user's notifications.
Register the device
import { registerForPush } from "@/lib/push";await registerForPush(); // asks permission, then POSTs the token
Call it after sign-in. The matching unregister call belongs in your sign-out path, or the handset keeps receiving notifications meant for the previous account.
Send yourself a test
curl -X POST localhost:8080/api/v1/push/test -b cookies.txt
Goes to every device registered to the signed-in user. If nothing arrives, the response says which token Expo rejected.
Send from your own code
services.Push.Go(ctx, []string{deal.OwnerID},"Deal won", deal.Title, map[string]string{"deal_id": deal.ID})
Sends in the background, 100 devices per request. Tokens Expo reports as no longer registered are deleted automatically, so the table does not fill with dead handsets.
Related plugins
Near neighbours, and what each one is for instead.
Everything else is on the marketplace, and four ways to extend says when a plugin is the wrong tool for the job.
