onebox

Guide 16 of 27

For your agent: .md · all guides

On this page
  1. What it costs
  2. How it fits together
  3. Steps
  4. Send to APNs directly (the alternative)
  5. Testing
  6. App Review
  7. Privacy policy and App Privacy
  8. Where the values go
  9. Check it works
  10. Common errors

Push notifications

Runs on: your browser (the Apple Developer account), your Mac (the app and EAS), and your box (the API sends the pushes).

A push notification reaches the user when the app is closed: “your import is ready”, “someone replied”, “your plan renews tomorrow”. This guide sets up the Apple key, asks for permission at the right moment, stores each device’s token on your server, and sends from your API through the Expo Push Service. You need it only when the app has a real reason to reach a user who is not looking at it. A reminder the user asked for is a good reason. “Come back to the app” is not.

What it costs

ItemCostNotes
Apple Push Notification service (APNs)includedPart of the Apple Developer Program.
Expo Push ServicefreeExpo charges nothing for it. Limit: 600 notifications per second per project, up to 100 messages per request.
Sending from the boxnothing extraOne HTTPS call per 100 messages.

Checked 2026-09-28 at https://docs.expo.dev/push-notifications/faq/ and https://docs.expo.dev/push-notifications/sending-notifications/.

How it fits together

app  ── permission, then the Expo push token ──>  your API  ──>  Postgres (push_tokens)

your API ── POST https://exp.host/--/api/v2/push/send ──>  Expo  ──>  APNs  ──>  iPhone
         <── a ticket per message (an id, or an error)

your API ── 15 minutes later: POST .../push/getReceipts ──>  delete dead tokens

Two tokens exist. Do not mix them up:

Steps

1. The APNs key

APNs accepts pushes only from a server that holds your team’s APNs key (a .p8 file). Expo’s servers use it for you. Pick one way:

Why Sandbox & Production: development builds (and the Simulator) use the APNs sandbox. TestFlight and App Store builds use production. A key for one environment only does not work for the other.

One team-scoped key works for every app in your team. Apple allows at most two team-scoped keys per environment, so reuse the key for your next app. Keep the .p8 file in your secrets tool (secrets.md).

The App ID also needs the Push Notifications capability. EAS turns it on for you on eas build: it syncs the capabilities with the entitlements that the expo-notifications plugin adds.

2. Add expo-notifications to the app

npx expo install expo-notifications expo-constants

app.json:

{ "expo": { "plugins": ["expo-notifications"] } }

This is a native change. Make a new development build. Expo Go does not support push notifications from SDK 53 on.

3. One notification handler, at the root

The handler decides what happens when a push arrives while the app is open.

// app/_layout.tsx, at module level, outside any component
import * as Notifications from "expo-notifications";

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

The trap: there is only one handler. Each call to setNotificationHandler removes the one before. If a feature screen or a library calls it again, your root handler is gone, and nothing warns you. Call it once, in app/_layout.tsx. Search the code for a second call: git grep -n setNotificationHandler.

Three more facts:

4. Ask for permission at the right moment

iOS shows the system prompt once. If the user taps “Don’t Allow”, the app can never show it again. Only the Settings app can change the answer. So do not ask at the first launch. Ask when the user does something that needs a push: turns on reminders, or starts an import that takes minutes.

Show your own short screen first: what you will send, and how often. Then call the system prompt.

// src/push.ts
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { Linking } from "react-native";
import { api } from "./api";   // your API client, with the user's token

export async function enablePush(): Promise<boolean> {
  let perm = await Notifications.getPermissionsAsync();
  if (perm.status !== "granted" && perm.canAskAgain) {
    perm = await Notifications.requestPermissionsAsync();
  }
  if (perm.status !== "granted") {
    if (!perm.canAskAgain) await Linking.openSettings();   // only after the user tapped "turn on"
    return false;                                          // the app keeps working without push
  }
  await registerPushToken();
  return true;
}

export async function registerPushToken() {
  const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
  const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });
  await api.post("/push-tokens", { token });
}

5. Store the token per user and device

One row per device. The token is the key, so a device that signs in as another user moves to that user.

create table push_tokens (
  token      text        primary key,   -- ExponentPushToken[...]
  user_id    text        not null,
  updated_at timestamptz not null default now()
);
create index push_tokens_user on push_tokens (user_id);

create table push_tickets (               -- sent messages whose receipt we still need to read
  id      text        primary key,
  token   text        not null,
  sent_at timestamptz not null default now()
);

The API needs three things:

This table breaks two rules from backend.md on purpose: the upsert moves a row to another owner, and the sender reads across users. Do the upsert in raw SQL, not through the tracked OwnerId entity, and mark the sender’s query as an IgnoreQueryFilters() review point.

6. Send from the API

Send from a background job, not inside a user’s request. The skill app-features:durable-jobs has the job pattern and already sends a push when a job is done.

Node, with Expo’s own SDK (npm install expo-server-sdk). It batches, throttles, retries and compresses for you:

import { Expo } from "expo-server-sdk";
const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });

export async function sendToUser(pool, userId, title, body, data = {}) {
  const { rows } = await pool.query("select token from push_tokens where user_id = $1", [userId]);
  const messages = rows.map((r) => ({ to: r.token, title, body, data }));
  for (const chunk of expo.chunkPushNotifications(messages)) {
    const tickets = await expo.sendPushNotificationsAsync(chunk);
    for (const [i, t] of tickets.entries()) {
      if (t.status === "ok") await pool.query("insert into push_tickets (id, token) values ($1, $2)", [t.id, chunk[i].to]);
      else if (t.details?.error === "DeviceNotRegistered") await pool.query("delete from push_tokens where token = $1", [chunk[i].to]);
    }
  }
}

.NET: Expo has no official .NET SDK. The HTTP API is small:

using System.Text.Json.Serialization;

public sealed record PushMessage(string To, string Title, string Body,
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] object? Data = null);
public sealed record PushResult(string Status, string? Id, string? Message, PushDetails? Details);
public sealed record PushDetails(string? Error);
sealed record Tickets(List<PushResult> Data);
sealed record Receipts(Dictionary<string, PushResult> Data);

// Register with: builder.Services.AddHttpClient<ExpoPush>(c => {
//   c.BaseAddress = new Uri("https://exp.host/");
//   c.DefaultRequestHeaders.Authorization = new("Bearer", builder.Configuration["EXPO_ACCESS_TOKEN"]); });
public sealed class ExpoPush(HttpClient http)
{
    public async Task<List<(PushMessage Msg, PushResult Ticket)>> SendAsync(IEnumerable<PushMessage> messages, CancellationToken ct)
    {
        var all = new List<(PushMessage, PushResult)>();
        foreach (var chunk in messages.Chunk(100))                  // at most 100 per request
        {
            using var res = await http.PostAsJsonAsync("--/api/v2/push/send", chunk, ct);
            res.EnsureSuccessStatusCode();                          // 429 or 5xx: retry later, with backoff
            var body = await res.Content.ReadFromJsonAsync<Tickets>(ct);
            all.AddRange(chunk.Zip(body!.Data));                   // tickets come back in message order
        }
        return all;
    }

    public async Task<Dictionary<string, PushResult>> ReceiptsAsync(IEnumerable<string> ids, CancellationToken ct)
    {
        var all = new Dictionary<string, PushResult>();
        foreach (var chunk in ids.Chunk(1000))                      // at most 1000 ids per request
        {
            using var res = await http.PostAsJsonAsync("--/api/v2/push/getReceipts", new { ids = chunk }, ct);
            res.EnsureSuccessStatusCode();
            foreach (var (id, r) in (await res.Content.ReadFromJsonAsync<Receipts>(ct))!.Data) all[id] = r;
        }
        return all;
    }
}

For each ticket: store the Id in push_tickets when Status is ok. Delete the token when Details.Error is DeviceNotRegistered.

Rules for the payload:

7. Read the receipts and remove dead tokens

A ticket with status: ok means Expo received the message. It does not mean Apple did. The receipt tells you that. Expo advises reading receipts 15 minutes after sending. It deletes them after 24 hours.

Run a job every 15 minutes:

  1. Read push_tickets rows older than 15 minutes.
  2. Ask for their receipts (at most 1000 ids per request).
  3. For each receipt with details.error = DeviceNotRegistered: delete that token from push_tokens. Apple asks you to stop sending to it.
  4. For InvalidCredentials: the APNs key is wrong or revoked. Log it as an error, so your error reporter tells you (crash-reports.md).
  5. For MessageRateExceeded: slow down and retry with backoff.
  6. Delete the ticket rows that got a receipt, and any older than 24 hours.

Log the error code, not the token.

By default, anyone who has an Expo push token can send to that device through Expo. Turn on enhanced push security in the EAS dashboard. Then every send needs an Expo access token in Authorization: Bearer .... Make an access token in your Expo account settings. Put it in your app secrets as EXPO_ACCESS_TOKEN, and list it in the API’s environment: block in docker-compose.yml. After you turn it on, requests without the token fail with UNAUTHORIZED.

9. Handle a tap

With Expo Router, open the screen from data.url. This also works when the tap launched the app:

// app/_layout.tsx
import { useEffect } from "react";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";

function useNotificationTaps() {
  useEffect(() => {
    const open = (n: Notifications.Notification) => {
      const url = n.request.content.data?.url;
      if (typeof url === "string") router.push(url);
    };
    const last = Notifications.getLastNotificationResponse();   // the tap that opened the app
    if (last?.notification) open(last.notification);
    const sub = Notifications.addNotificationResponseReceivedListener((r) => open(r.notification));
    return () => sub.remove();
  }, []);
}

Only accept routes inside your app. Check the user’s access on the server when the screen loads its data, as always.

Send to APNs directly (the alternative)

You can skip Expo’s service. Get the native token with getDevicePushTokenAsync() and send it to your API. The server then:

It removes one third party from the path. It costs more code: HTTP/2, JWT signing, one token per environment, and no receipts to lean on. Start with Expo’s service. Move when you have a reason.

Testing

Your coding agent can drive the Simulator tests with dev:test-loop.

App Review

ship-ios:app-store-ready looks for the usual rejection causes before you submit.

Privacy policy and App Privacy

Add a line like this to your privacy policy (privacy-and-support-pages.md):

If you turn on notifications, we store a device token with your account so we can send them. Notifications pass through Expo’s push service and Apple Push Notification service. Expo does not store the content after delivery. You can turn notifications off in the iOS Settings app, and we delete the token when you sign out or delete your account.

Expo’s FAQ says it keeps notification content only in memory and queues until delivery. With direct APNs, name only Apple.

In App Store Connect’s App Privacy answers, Apple’s definitions do not name push tokens. Your server links the token to the account. Read the Identifiers definitions and decide for your app.

Where the values go

ValueWhere
APNs key (.p8) and Key IDEAS, through eas credentials; keep the file in your secrets tool. One key serves all your apps.
EAS projectIdthe app config, written by eas init
Expo push tokenspush_tokens in the app’s Postgres on the box
EXPO_ACCESS_TOKEN (enhanced push security)your app secrets for production; listed under environment: in docker-compose.yml

Check it works

Common errors

Wrong or out of date? Fix it on GitHub.