Plugin

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 push
grit migrate
pnpm 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

EndpointDoes
POST /api/v1/push/tokensRegisters the signed-in device. Anything but an Expo push token is refused.
POST /api/v1/push/tokens/removeUnregisters it, if it is yours. The answer is the same either way.
POST /api/v1/push/testSends 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 DeviceNotRegistered for 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; registerForPush returns null there and the app carries on. Store builds need an EAS project id; set EXPO_ACCESS_TOKEN if 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.