Sending Pushes from Your Backend

Post to Expo's push service, batch messages, read tickets and receipts, and prune tokens that no longer work.

SyntaxPOST https://exp.host/--/api/v2/push/send with body [{ to, title, body, data }] (max 100)

Your backend sends pushes by posting JSON to https://exp.host/--/api/v2/push/send. A message is { to, title, body, data } plus optional fields such as sound, badge, channelId, ttl and priority. to is one Expo push token or an array of them. The whole payload is limited to about 4 KiB, so put identifiers in data, not records.

Batching

Send at most 100 message objects per request. The Node package expo-server-sdk does the splitting with chunkPushNotifications(messages) and sends a chunk with sendPushNotificationsAsync(chunk). Object shorthand helps here: { to, title } is the same as { to: to, title: title }.

Tickets, then receipts

Each message comes back as a ticket: { status: 'ok', id }, or status: 'error' with details.error. A ticket only means Expo accepted it. The real outcome is the receipt, fetched later with the ticket ids via getPushNotificationReceiptsAsync, at most 1000 ids per request. DeviceNotRegistered means the app was uninstalled or permission was revoked: delete that token and stop sending to it. MessageTooBig, MismatchSenderId and InvalidCredentials are bugs in your payload or credentials, not in the device, while MessageRateExceeded means you are sending to that one device too often and should back off and retry.

Delivery is never guaranteed

APNs and FCM are best-effort. A device that is off, in Doze mode, or out of coverage may never get the message, and ttl only says how long to keep trying. Never use a push as the only way state reaches the app; fetch on launch as well.

Example

Example · tsx
import { Expo, type ExpoPushMessage, type ExpoPushTicket } from 'expo-server-sdk';

const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });

type Recipient = { token: string; orderId: string };

// Returns the tokens to delete, plus the ticket ids to check later.
export async function notifyShipped(recipients: Recipient[]) {
  const messages: ExpoPushMessage[] = [];
  const invalid: string[] = [];
  const ticketIds: string[] = [];

  for (const { token, orderId } of recipients) {
    // Cheap local check. A well-formed token can still be dead.
    if (!Expo.isExpoPushToken(token)) {
      invalid.push(token);
      continue;
    }
    messages.push({
      to: token,
      title: 'Order shipped',
      body: 'Tap to track your parcel.',
      data: { screen: 'order', id: orderId }, // read by the app on tap
      channelId: 'default', // Android: this channel must exist in the app
      ttl: 3600, // stop trying after an hour
    });
  }

  // The service takes 100 messages per request; chunking handles that.
  for (const chunk of expo.chunkPushNotifications(messages)) {
    const tickets: ExpoPushTicket[] = await expo.sendPushNotificationsAsync(chunk);
    tickets.forEach((ticket, index) => {
      if (ticket.status === 'ok') {
        ticketIds.push(ticket.id); // store with the token to match receipts later
      } else if (ticket.details?.error === 'DeviceNotRegistered') {
        invalid.push(chunk[index].to as string);
      }
    });
  }

  return { invalid, ticketIds };
}

When to use it

  • Tell a user their order shipped, with the order id in the payload for deep-linking.
  • Fan a single announcement out to every device of every subscribed user.
  • Delete push tokens belonging to people who uninstalled the app.
  • Debug why a push was accepted by Expo but never reached the phone.

More examples

The same send with plain fetch

Use this when your backend is not Node and the Expo SDK is unavailable: it is one plain HTTP call from PHP, Go, Python or anything else. You take over the two things the SDK did for you, namely splitting into batches of 100 and backing off when the service answers MessageRateExceeded or TOO_MANY_REQUESTS. Tickets come back in the order you sent, which is how you match each one to its token.

Example · tsx
type PushMessage = {
  to: string | string[];
  title?: string;
  body?: string;
  data?: Record<string, unknown>;
  channelId?: string;
};

type TicketResponse = {
  data: Array<{ status: string; id?: string; details?: { error?: string } }>;
};

// messages.length must be 100 or fewer.
export async function sendBatch(messages: PushMessage[]) {
  const response = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      accept: 'application/json',
      // Required only when you have enabled enhanced push security.
      authorization: `Bearer ${process.env.EXPO_ACCESS_TOKEN}`,
    },
    body: JSON.stringify(messages),
  });
  const payload = (await response.json()) as TicketResponse;
  return payload.data; // one ticket per message, in the same order
}

Pruning tokens from receipts

Receipts are keyed by ticket id, not by token, so the map from ticket id to token has to be stored at send time. A scheduled job that sends, waits, then deletes the rows this returns is what keeps your device table from filling with uninstalled apps. Other receipt errors, such as MismatchSenderId, point at your credentials: fix those rather than deleting the token.

Example · tsx
import { Expo } from 'expo-server-sdk';

const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });

// Run at least fifteen minutes after sending. Receipts do not live long.
export async function deadTokens(
  ticketIdToToken: Map<string, string>,
): Promise<string[]> {
  const dead: string[] = [];
  const ids = [...ticketIdToToken.keys()];

  for (const chunk of expo.chunkPushNotificationReceiptIds(ids)) {
    const receipts = await expo.getPushNotificationReceiptsAsync(chunk);
    for (const [ticketId, receipt] of Object.entries(receipts)) {
      if (receipt.status !== 'error') continue;
      const token = ticketIdToToken.get(ticketId);
      // The one error that means the token itself is finished.
      if (token && receipt.details?.error === 'DeviceNotRegistered') {
        dead.push(token);
      }
    }
  }
  return dead;
}

Batching without a library

Six lines, no dependency, and it is the piece people most often skip before discovering the 100-message limit in production. Pair it with a small delay between batches: the service accepts 600 notifications per second per project, and exceeding that limit answers with TOO_MANY_REQUESTS. MessageRateExceeded is a different error, returned when you send too frequently to one particular device. Either way, retry with exponential backoff rather than immediately.

Example · tsx
export function chunk<T>(items: T[], size = 100): T[][] {
  const out: T[][] = [];
  for (let index = 0; index < items.length; index += size) {
    out.push(items.slice(index, index + size));
  }
  return out;
}

const tokens = Array.from({ length: 250 }, (_, i) => `ExponentPushToken[t${i}]`);
const batches = chunk(tokens);
console.log(batches.length, batches[0].length, batches[2].length);
// Output: 3 100 50

Discussion

  • Be the first to comment on this lesson.