Back to Tutorials

Firebase Functions for Expo Push

Use Cloud Functions as the trusted backend for storing Expo push tokens, sending notifications, checking receipts, and cleaning up dead devices.

June 16, 2026 15 min read Paddy B
ExpoFirebase FunctionsPush NotificationsFirestoreReact Native

The short version

Register Expo push tokens from the app, store them under each signed-in user, send through the Expo Push Service from Cloud Functions, save ticket IDs, then check receipts on a schedule and delete tokens that are no longer registered.

Push notifications should not be sent directly from the mobile app. A backend can decide who is allowed to send, prevent spam, protect message content, and clean up old tokens.

1. Store tokens by user and installation

A user can have multiple devices. Store each installation as its own document.

users/{userId}/pushTokens/{tokenId} { "token": "ExponentPushToken[...]", "platform": "ios", "appVersion": "1.4.0", "createdAt": "...", "lastSeenAt": "..." }

Use a deterministic token document ID if you want to upsert easily. For example, hash the token on the client or let the server create an ID after validating the token string.

2. Register the token from Expo

After the app gets an ExpoPushToken, write it through an authenticated HTTPS or callable function. That gives the backend a chance to validate the current user.

await fetch("https://REGION-PROJECT.cloudfunctions.net/registerPushToken", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${await auth.currentUser?.getIdToken()}`, }, body: JSON.stringify({ token, platform: Platform.OS, appVersion: Constants.expoConfig?.version, }), });

3. Validate and save in Cloud Functions

The function should reject signed-out callers and invalid token shapes.

const { onRequest } = require("firebase-functions/v2/https"); const { getAuth } = require("firebase-admin/auth"); const { getFirestore, FieldValue } = require("firebase-admin/firestore"); const db = getFirestore(); const expoTokenPattern = /^ExponentPushToken\[[A-Za-z0-9_-]+\]$/; exports.registerPushToken = onRequest({ region: "europe-west2" }, async (req, res) => { const idToken = req.headers.authorization?.replace("Bearer ", ""); if (!idToken) return res.status(401).send("Missing token"); const decoded = await getAuth().verifyIdToken(idToken); const { token, platform, appVersion } = req.body; if (!expoTokenPattern.test(token)) return res.status(400).send("Invalid Expo token"); const tokenId = Buffer.from(token).toString("base64url"); await db.doc(`users/${decoded.uid}/pushTokens/${tokenId}`).set({ token, platform, appVersion, lastSeenAt: FieldValue.serverTimestamp(), createdAt: FieldValue.serverTimestamp(), }, { merge: true }); res.json({ ok: true }); });

If you already use callable functions in the app, use onCall instead. The shape is different, but the security idea is the same: verify the Firebase user before storing a token.

4. Send notifications from a trusted trigger

For a direct user notification, load the target user's tokens and call Expo Push Service.

async function sendExpoPush(tokens, message) { const response = await fetch("https://exp.host/--/api/v2/push/send", { method: "POST", headers: { Accept: "application/json", "Accept-Encoding": "gzip, deflate", "Content-Type": "application/json", }, body: JSON.stringify(tokens.map((to) => ({ to, title: message.title, body: message.body, data: message.data || {}, sound: "default", }))), }); return response.json(); }

Use functions for events that are already server-side: a new chat message, a due reminder, a status change, or a scheduled digest. Avoid letting one client choose arbitrary recipients.

5. Save tickets and check receipts

Expo returns tickets when it accepts messages. Save the ticket IDs, then check receipts later from a scheduled function.

exports.checkPushReceipts = onSchedule({ schedule: "every 30 minutes", region: "europe-west2", }, async () => { const pending = await db.collection("pushTickets") .where("status", "==", "pending") .limit(100) .get(); const ids = pending.docs.map((doc) => doc.id); if (!ids.length) return; const response = await fetch("https://exp.host/--/api/v2/push/getReceipts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }), }); const receipts = await response.json(); // Mark tickets delivered or failed, then delete DeviceNotRegistered tokens. });

Receipt checks are where production systems get cleaner over time. If a device is gone, remove its token and stop sending to it.

6. Production safeguards

  • Throttle user-triggered sends by user and message type.
  • Keep notification payloads small and avoid sensitive personal data.
  • Store enough audit data to answer who triggered a notification and why.
  • Delete tokens on logout if your privacy model requires device unlinking.
  • Use scheduled cleanup for stale tokens that have not been seen recently.
  • Test with the Firebase Emulator Suite before sending from production functions.