better-push
Delivery channels

Expo push setup

Deliver through the Expo push service - tickets, receipts, and why receipts need a queue.

The expo provider relays notifications through Expo's push service, which forwards them to APNs and FCM on your behalf. The app registers an ExponentPushToken[...] instead of a raw device token, and you configure no Apple or Firebase credentials in better-push at all.

src/push.ts
import { expo } from "@better-push/core/providers/expo";

export const push = betterPush({
  providers: [expo()],
  // ...
});

Is this the right choice

It is a genuine trade, not an easier version of the same thing.

Raw APNs/FCM (apns(), fcm())Expo (expo())
Delivery pathYour server to Apple/GoogleYour server to Expo to Apple/Google
CredentialsYour p8 key, your service accountNone, or one Expo access token
Dead-token signalImmediate, in the send responseLater, in a receipt
delivered_atNever setSet from receipts

@better-push/core/native registers raw tokens by default. Reach for Expo when your app is distributed with Expo's push credentials, which is the case it exists for.

Both can be configured at once: a device row names its own provider, so one backend serves an app that registers either.

Credentials

A token-less Expo project needs nothing. If your project has enhanced push security enabled, pass the access token:

expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });

Expo's own credentials for APNs and FCM are configured in your Expo project, not here. That is the point of the relay.

Registering a token

import { usePushRegistration } from "@better-push/core/native";

const registration = usePushRegistration({
  mode: "expo",
  projectId: Constants.expoConfig?.extra?.eas?.projectId,
});

projectId is required in a bare or EAS build. A token minted against the wrong project registers successfully and then silently never delivers, which is among the harder failures to diagnose - so pass it.

See React Native for the rest of the client.

Tickets and receipts

This is the part that is different from every other provider. Expo answers a send with a ticket, not an outcome:

{ "data": [{ "status": "ok", "id": "XXXX-XXXX" }] }

"ok" means Expo accepted the message for delivery. It does not mean the device got it, and it does not mean the token is alive. DeviceNotRegistered - the entire dead-token signal for this transport - arrives later, in a receipt fetched by ticket id.

So better-push schedules a receipt check:

The job id is derived from the notification and the provider, so a retry of the delivery job cannot schedule the check twice.

Receipts require a queue

With no queue there is nothing to run fifteen minutes later, so the tickets are the final word:

CapabilityDB only+ dbQueue+ bullmq
Expo receiptstickets are final; dead tokens pruned on the next sendfetched on the next poll after the delayfetched at the delay, exactly
delivered_at on push rowsnever setset from receiptsset from receipts

Nothing breaks without a queue. A token that has gone away simply fails its next send with DeviceNotRegistered and is disabled then, one notification later than it could have been.

export const push = betterPush({
  providers: [expo()],
  queue: dbQueue(),
  receiptDelay: "15m",   // the default, and Expo's own guidance
});

A receipt Expo does not have yet is left alone and the job retries on the queue's own backoff, bounded by maxAttempts like every other job.

Error mapping

Expo details.errorbetter-push codeDevice disabled
DeviceNotRegisteredinvalid_tokenYes
MessageTooBigpayload_too_largeNo
MessageRateExceededrate_limitedNo
MismatchSenderIdprovider_errorNo
InvalidCredentialsprovider_errorNo
HTTP 429 for a chunkrate_limited for every message in itNo
HTTP 5xx for a chunkprovider_error for every message in itNo

The last two rows matter: a chunk rejected at the HTTP level produces no tickets at all, and every message in it still gets a result. MismatchSenderId and InvalidCredentials are configuration faults, so the device stays enabled - the fix is in your Expo project, not on the phone.

Limits

Expo documents 100 messages per send and 1000 ids per receipt request; the provider chunks at both and neither is worth raising. The payload cap is 4 KiB including data.

expo({ maxBatch: 100, maxReceipts: 1000 });

On this page