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.
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 path | Your server to Apple/Google | Your server to Expo to Apple/Google |
| Credentials | Your p8 key, your service account | None, or one Expo access token |
| Dead-token signal | Immediate, in the send response | Later, in a receipt |
delivered_at | Never set | Set 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:
| Capability | DB only | + dbQueue | + bullmq |
|---|---|---|---|
| Expo receipts | tickets are final; dead tokens pruned on the next send | fetched on the next poll after the delay | fetched at the delay, exactly |
delivered_at on push rows | never set | set from receipts | set 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.error | better-push code | Device disabled |
|---|---|---|
DeviceNotRegistered | invalid_token | Yes |
MessageTooBig | payload_too_large | No |
MessageRateExceeded | rate_limited | No |
MismatchSenderId | provider_error | No |
InvalidCredentials | provider_error | No |
| HTTP 429 for a chunk | rate_limited for every message in it | No |
| HTTP 5xx for a chunk | provider_error for every message in it | No |
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 });