Expo Push Notifications Backend Setup (Step by Step)

An Expo push notification backend has four jobs: store each user's push tokens, send messages to them (in batches, from a trusted server), check delivery receipts, and delete tokens that stop working. You can do this with Expo's own push service, which takes Expo push tokens and forwards to Apple and Google, or by sending native APNs and FCM tokens to a backend that delivers directly. This guide builds both on one Postgres backend, with real code for each step.

#Two ways to deliver

Path A: Expo push servicePath B: native tokens to your backend
Token in the appgetExpoPushTokenAsync() (ExponentPushToken[...])getDevicePushTokenAsync() (APNs or FCM token)
Who talks to Apple and GoogleExpoYour backend
CredentialsUploaded to Expo (EAS)Uploaded to your backend
You writeToken table, sender, receipt checker, cleanupVery little if the backend has push built in
CostExpo's push service has no cost (as of November 2026)Your backend's plan
Good forExisting Expo push setups, Expo-only teamsSending from database triggers, an in-app inbox

Facts from Expo's push docs that shape the backend: the send endpoint is https://exp.host/--/api/v2/push/send, each request takes up to 100 messages, the limit is 600 notifications per second per project, a successful ticket only means Expo received the message, and receipts (fetched later, up to 1,000 ids per request) say whether Apple or Google accepted it. Receipts are cleared after 24 hours, and Expo suggests checking them about 15 minutes after sending.

One more thing before you start: since SDK 53, Expo Go does not support push notifications. Use a development build.

#The app side (both paths)

Create one client with AsyncStorage so the session survives restarts. The StitchDB JavaScript SDK runs on React Native; pass a storage in auth.storage (install):

JavaScript
// lib/stitchdb.js
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from 'stitchdb-js'

export const stitchdb = createClient(
  process.env.EXPO_PUBLIC_STITCHDB_URL,
  process.env.EXPO_PUBLIC_STITCHDB_ANON_KEY,
  { auth: { storage: AsyncStorage, persistSession: true, autoRefreshToken: true } },
)

Only the anon key goes in the app. The service_role key, which bypasses Row Level Security, stays on the server.

Ask for permission after the user does something that shows why notifications help (see push notification best practices):

JavaScript
import * as Notifications from 'expo-notifications'
import Constants from 'expo-constants'
import { Platform } from 'react-native'

export async function getPermission() {
  const { status: existing } = await Notifications.getPermissionsAsync()
  let status = existing
  if (existing !== 'granted') {
    status = (await Notifications.requestPermissionsAsync()).status
  }
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
    })
  }
  return status === 'granted'
}

#Path A: Expo push service with your own token table

#1. A table for tokens

SQL
create table public.expo_push_tokens (
  token text primary key,
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  platform text not null check (platform in ('ios', 'android')),
  updated_at timestamptz not null default now()
);
alter table public.expo_push_tokens enable row level security;

create policy "users manage their own tokens" on public.expo_push_tokens
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

The token is the primary key, so the same device signing in as a different user replaces the old row instead of notifying two people.

#2. Register after sign-in

JavaScript
async function registerExpoToken() {
  if (!(await getPermission())) return
  const projectId =
    Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId
  const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId })

  await stitchdb.from('expo_push_tokens').upsert({
    token,
    platform: Platform.OS,
    updated_at: new Date().toISOString(),
  })
}

// on sign-out, before signOut():
await stitchdb.from('expo_push_tokens').delete().eq('token', token)

If another user registered this device before, the upsert hits a row that policy does not let the new user update. Handle that by deleting the token on sign-out (above), or by moving the upsert into a small security definer function that reassigns the token to auth.uid().

#3. Send from an Edge Function

Sending needs a trusted place. An Edge Function reads tokens with the service_role key (available as STITCHDB_SERVICE_ROLE_KEY), batches them and calls Expo. If you turned on Expo's enhanced push security, add your Expo access token as a secret named EXPO_ACCESS_TOKEN (secrets).

TypeScript
// functions/send-push/index.ts
import { createClient } from 'https://stitchdb.com/sdk/stitchdb-js.mjs'

const admin = createClient(Deno.env.get('STITCHDB_URL')!, Deno.env.get('STITCHDB_SERVICE_ROLE_KEY')!, {
  auth: { persistSession: false },
})

Deno.serve(async (req) => {
  const { user_ids, title, body, data } = await req.json()

  const { data: rows } = await admin
    .from('expo_push_tokens').select('token').in('user_id', user_ids)

  const messages = (rows ?? []).map((r) => ({ to: r.token, title, body, data, sound: 'default' }))
  const tickets: unknown[] = []

  for (let i = 0; i < messages.length; i += 100) {          // Expo: max 100 per request
    const res = await fetch('https://exp.host/--/api/v2/push/send', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...(Deno.env.get('EXPO_ACCESS_TOKEN')
          ? { Authorization: `Bearer ${Deno.env.get('EXPO_ACCESS_TOKEN')}` } : {}),
      },
      body: JSON.stringify(messages.slice(i, i + 100)),
    })
    const { data: batch } = await res.json()
    batch.forEach((t: any, j: number) => tickets.push({ ...t, token: messages[i + j].to }))
  }

  // Remember ticket ids for the receipt check; drop tokens Expo already rejects
  for (const t of tickets as any[]) {
    if (t.status === 'ok') {
      await admin.from('expo_push_tickets').insert({ id: t.id, token: t.token })
    } else if (t.details?.error === 'DeviceNotRegistered') {
      await admin.from('expo_push_tokens').delete().eq('token', t.token)
    }
  }
  return Response.json({ sent: messages.length })
})

Keep JWT verification on so only callers with your keys can run it (function auth). Your own server calls it with the service_role key; do not call it from the app with a user token unless you also check who may notify whom.

#4. Check receipts and clean up

Store ticket ids, then check them later:

SQL
create table public.expo_push_tickets (
  id text primary key,
  token text not null,
  created_at timestamptz not null default now()
);
alter table public.expo_push_tickets enable row level security; -- no policies: server only

A second function, check-receipts, selects tickets older than 15 minutes, POSTs their ids (up to 1,000 at a time) to https://exp.host/--/api/v2/push/getReceipts, deletes tokens whose receipt has DeviceNotRegistered, and deletes the checked tickets. Run it every 15 minutes with cron and pg_net, storing the service role key in Vault first:

SQL
select cron.schedule('expo-receipts', '*/15 * * * *', $$
  select net.http_post(
    url := 'https://<project-ref>.p.stitchdb.com/functions/v1/check-receipts',
    headers := jsonb_build_object(
      'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key'),
      'Content-Type', 'application/json'),
    body := '{}'::jsonb)
$$);

#5. Trigger sends from data changes

To notify when an order ships, call the function from a trigger with pg_net, or from your server after the update. Keep the trigger itself quick; net.http_post returns at once and sends in the background (HTTP from SQL).

That is a working Expo push backend: a table, two functions and a cron job.

#Path B: native tokens and built-in push

StitchDB push notifications send through APNs and FCM directly and do not accept Expo push tokens. In exchange you skip the token table, the batching, the receipts and the cleanup. In Expo, get the native token:

JavaScript
const { data: token } = await Notifications.getDevicePushTokenAsync()
await stitchdb.push.register({ platform: Platform.OS === 'ios' ? 'ios' : 'android', token })

// on sign-out
await stitchdb.push.unregister({ platform: Platform.OS === 'ios' ? 'ios' : 'android', token })

Setup in Studio › Integrations › Push: upload your APNs .p8 key with its Key ID, Team ID and bundle ID (turn off Production for development builds that use the APNs sandbox), and a Firebase service account JSON for Android. These are the same kinds of credentials EAS asks you for.

Then send from SQL or a trigger:

SQL
select push.notify('<user-id>', 'Order shipped', 'Your order #1042 is on its way', '{"order": 1042}');

create function notify_shipped() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
  perform push.notify(new.user_id, 'Order shipped', 'Order #' || new.id || ' is on its way',
    jsonb_build_object('order', new.id));
  return new;
end $$;

create trigger order_shipped after update of status on public.orders
  for each row when (new.status = 'shipped') execute function notify_shipped();

Messages are queued and delivered within a second or two, temporary errors are retried, invalid tokens are turned off automatically, and every message to a user also lands in an in-app inbox (stitchdb.push.inbox()), which updates live. The Free plan includes 10,000 push notifications a month, Pro 1,000,000.

Register again whenever the token changes, and after a different user signs in on the same device: a device belongs to the signed-in user. More on the whole Expo stack in React Native backend.

#Which path to pick

  • You already use Expo push tokens and EAS credentials: Path A. Nothing changes in the app.
  • You want notifications from database triggers with an inbox and no cleanup code: Path B.
  • You might leave Expo later: Path B. Native tokens work from any client.

#FAQ

#Do I need a server for Expo push notifications?

You need somewhere trusted to send from, because the send request should not come from the app. An Edge Function or a database trigger is enough; you do not need to run your own server.

#Why do push notifications not work in Expo Go?

From SDK 53, Expo Go does not support push. Build a development build with EAS.

#Can StitchDB send to Expo push tokens?

No. StitchDB push takes native APNs and FCM tokens from getDevicePushTokenAsync(). To keep Expo tokens, send through Expo's service from an Edge Function (Path A).

#What does DeviceNotRegistered mean?

The device can no longer receive notifications, usually after an uninstall. Delete that token and stop sending to it.

#How often should I check Expo push receipts?

Around 15 minutes after sending, and always within 24 hours, after which Expo clears them.

Build your Expo backend: create a free StitchDB project, then follow push notifications.