Expo push delivery
pkg/push sends mobile alerts through Expo using the standard HTTP client. It can
run without app assembly, PostgreSQL, Redis or a recipient model. The framework
notifier composes this same client
with stored installations, delivery retries and receipt polling.
Expo hands messages to the platform's FCM/APNs infrastructure. This adapter sends OS notifications, not realtime application events, chat messages or browser Web Push. There is no Firebase SDK or native FCM/APNs sender in the Go backend.
| Start here | Use |
|---|---|
| Verify Expo credentials and one destination | Standalone example |
| Integrate a mobile client | Token registration |
| Send to all eligible devices for a user/admin | Durable notifications |
| Diagnose accepted-but-unconfirmed delivery | Receipts |
Prepare Expo and the mobile app#
Use an Expo development/production build with expo-notifications and configured
platform push credentials. Set the EAS project ID explicitly when obtaining an Expo
push token. Use a supported physical device or supported emulator/simulator; current
requirements and credential setup are in Expo's setup guide.
This framework's remote-push walkthrough uses your built application, not Expo Go.
Install/configure expo-notifications, rebuild after native configuration changes,
and request notification permission before registering a token. The Go backend
uses an Expo access token only when your project enables enhanced push security.
Never ship that server credential inside the mobile application.
Application configuration#
PUSH_ENABLED=true
PUSH_EXPO_PROJECT_ID=your-eas-project-id
PUSH_EXPO_ACCESS_TOKEN=your-expo-enhanced-push-security-token
Project ID scopes the persisted registration namespace; Expo routes using the token. Get client tokens using that same EAS project ID. The server cannot establish token ownership or its project from syntax alone. Use authenticated registration endpoints. Enable enhanced push security in Expo and supply its access token in production. The access token is optional for projects which have not enabled that feature. Do not place it in the mobile bundle or commit it to configuration files.
These settings are loaded by normal app/worker entrypoints. They construct the shared client but send nothing during startup. The project ID identifies the notification subscription namespace; it is not a recipient, installation or ticket ID.
Try one alert#
First obtain an Expo push token from your configured app. The standalone example
reads process environment variables directly; it does not load .env or use
APP_ENV_FILE. Supply the values to the shell/process without committing them.
PUSH_REGISTRATION='ExpoPushToken[replace-with-your-registration]' \
go run ./examples/push
If enhanced push security is enabled, also export PUSH_EXPO_ACCESS_TOKEN to this
process. The example sends a real alert and prints an accepted ticket ID. It
requires neither SQL nor Redis. A ticket means Expo accepted work; it does not
prove that the device displayed it.
After approximately 15 minutes, check that ticket without sending another alert:
PUSH_TICKET_ID='replace-with-the-ticket-id' go run ./examples/push
The output distinguishes a pending receipt, a handoff confirmation and an error.
When PUSH_TICKET_ID is set, the example takes the receipt path even if a registration
is also present. A receipt lookup failure is a reason to retry the lookup, not to
send another notification.
Send independently#
This function-body fragment imports os, time and github.com/wisalgroup/labena/pkg/push.
It assumes ctx, an authorized expoPushToken, and a string exportID:
sender, err := push.NewExpo(push.ExpoConfig{
AccessToken: os.Getenv("PUSH_EXPO_ACCESS_TOKEN"),
})
if err != nil { return err }
expires := time.Now().Add(time.Hour)
ticket, err := sender.Send(ctx, expoPushToken, push.Message{
Title: "Your export is ready",
Body: "Open the application to download it.",
Data: map[string]string{"export_id": exportID},
ExpiresAt: &expires,
})
if err != nil { return err }
// Persist ticket.ID. Acceptance only means Expo queued this message.
Construct once per process and reuse the sender. Calls use their caller's context; do not replace it with a detached background context inside business operations.
Inspect a receipt#
Later, in another request or background job (typically after 15 minutes):
receipt, err := sender.Receipt(ctx, ticket.ID)
if err != nil {
return err // Retry this lookup; do not resend the notification.
}
if !receipt.Found {
return nil // Still pending: schedule another lookup.
}
if receipt.Error != nil {
return receipt.Error // Definitive handoff failure; inspect its classification.
}
// Expo confirmed handoff to the platform provider, not display or user receipt.
Persist the ticket ID if the process can restart before lookup. No background poller
runs inside pkg/push; your caller owns that lifecycle. See
Expo's send/receipt lifecycle
for provider-side behavior.
| Outcome | Meaning | Next step |
|---|---|---|
| Send returns a ticket | Expo accepted the request | Save the ticket and check later |
| Receipt returns a top-level error | Lookup did not complete reliably | Retry receipt lookup |
Found == false |
Receipt is not available yet | Schedule another lookup |
Found == true, Error == nil |
Expo confirmed provider handoff | Record success; this is not user receipt/read state |
Found == true, Error != nil |
Definitive provider handoff failure | Inspect classification before retry or disabling the destination |
The framework notifier supplies this persisted polling lifecycle if you do not want to implement it separately.
Control limits and failures#
The shared push.Sender contract defines both operations once. Ticket acknowledges
queue acceptance. Receipt.Found distinguishes pending from success, and
Receipt.Error distinguishes a definitive failure from a lookup/network error.
*push.Error carries safe codes, permanence, invalid-destination status, and retry timing.
Provider bodies and registration values are never included in error messages.
NewExpo borrows an optional HTTPClient transport and rejects redirects to avoid
forwarding credentials. Each operation has a 30-second deadline, with at most six
concurrent requests per shared client. It performs no automatic retries and starts
no background workers. Respect Expo's project-level limits across replicas; HTTP 429
and Retry-After are surfaced for backoff. Reuse clients rather than constructing one
per message. Configure worker throughput to match your project budget.
Only ExpoPushToken[...] and ExponentPushToken[...] registrations are accepted;
native FCM/APNs tokens must be replaced by Expo registrations. Content is validated
and bounded before I/O. ExpiresAt maps to Expo's expiration timestamp. Never send
passwords, login codes, credentials or sensitive content in OS notifications.
| Field or limit | Contract |
|---|---|
Title |
Nonblank, at most 200 bytes |
Body |
At most 2000 bytes |
Data |
String keys and values; resource references, not authoritative state |
Encoded Message |
At most 3500 bytes before provider serialization |
ExpiresAt |
Optional expiry; elapsed content is rejected/skipped |
| Concurrency | At most six HTTP operations per sender instance |
| Operation deadline | At most 30 seconds, or a shorter caller deadline |
Data keys from, message_type, and prefixes google. / gcm. are reserved.
The adapter chooses the default notification sound; it currently exposes no
sound, badge, Android-channel selector or bulk-send API. Add such options only
alongside their provider validation and behavior.
Classify errors using errors.As, not provider strings:
var failure *push.Error
if errors.As(err, &failure) {
// failure.InvalidDestination: disable this registration version.
// failure.Permanent: correct content/credentials before retrying.
// failure.RetryAfter: minimum delay before another eligible attempt.
}
Import errors. Common safe codes are unregistered, rate_limited, credentials,
invalid_argument and unavailable. A network failure during Send can leave
acceptance uncertain. The standalone sender deliberately does not choose a retry
policy for that operation. See the notifier's delivery lifecycle
for the framework's policy.
Durable notifications#
The framework notifier uses the same client. Each installation gets its own delivery
row and ticket. After acceptance, status is awaiting_receipt. Workers check after
15 minutes, then every five minutes while a receipt is missing or lookup fails
(respecting longer provider Retry-After). Polling does not consume the send-attempt
budget. A worker restart resumes lookup of the same persisted ticket.
An ok receipt marks the row sent. DeviceNotRegistered disables only the matching
registration version. A definitive retryable receipt failure schedules a new send;
a receipt API outage does not. After 24 hours without confirmation, the row fails
without automatically resending. Operator replay clears the old ticket and may cause
a duplicate. A crash after Expo acceptance but before SQL stores its ticket can also
cause a duplicate; exactly-once delivery is not claimed.
Synchronous Notifier.Send returns Result.PushTicketID; it does not persist or
poll that ticket. The caller must check receipts. Prefer Enqueue when this should
happen automatically through the existing worker lifecycle.
Use notifier.Enqueue with Message.Push after the authenticated installation
has been registered. PostgreSQL snapshots each eligible installation and content;
only delivery IDs enter Asynq. The dispatcher runs with workers and needs no cron
scheduler. Subscription inactivity pruning is a separate centralized schedule.
The shipped Identity welcome builder selects database and email; it does not implicitly send a push. Your module selects push explicitly when that message is useful as an OS alert. See selecting channels.
Expo mobile client#
After permission and token acquisition, send the Expo registration to the
framework's authenticated subscription route. The following helper assumes your
application supplies its EAS projectId, a locally persisted installation UUID,
API origin and the user's Identity access token:
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export async function registerPush({ projectId, installationID, apiURL, accessToken }) {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default', importance: Notifications.AndroidImportance.DEFAULT,
});
}
const permission = await Notifications.requestPermissionsAsync();
if (permission.status !== 'granted') return null;
const { data: registration } = await Notifications.getExpoPushTokenAsync({ projectId });
const response = await fetch(`${apiURL}/api/v1/notifications/subscriptions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ installation_id: installationID, registration }),
});
if (!response.ok) {
throw new Error(`Push registration failed (${response.status})`);
}
const { data: subscription } = await response.json();
return subscription.id;
}
apiURL must be reachable from the device; its localhost is not your development
computer. Save the returned subscription ID. Retry failed registration through your
normal client request policy and refresh it on startup/token rotation. Do not log
the registration or access token.
Before switching accounts, detach the old subscription:
const response = await fetch(`${apiURL}/api/v1/notifications/subscriptions/${subscriptionID}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) throw new Error(`Push detachment failed (${response.status})`);
Use the previous account's valid token. Identity logout also detaches registrations bound to that session. Detachment cannot retract a provider request already in flight. A registration token is a delivery address, not an authentication credential.
For foreground presentation, configure Notifications.setNotificationHandler at
application startup according to your UX. Subscribe to received/tap events and
remove listeners when the owning component unmounts. On a tap, authenticate and
fetch current authorized state using the payload's resource ID; the push body must
not decide authorization. See Expo's incoming-notification guide.
Framework notifications add notification_key and notification_type string data.
Use the key to deduplicate client handling. These reserved values override values
supplied by a module. Direct pkg/push calls do not add them automatically.
Test and troubleshoot push#
go test ./pkg/push/... ./internal/platform/notification/...
Provider tests use a local fake HTTP transport to exercise deadlines, safe errors
and receipt parsing. PostgreSQL-dependent notification tests skip unless
DATABASE_TEST_POSTGRES is configured; use the integration gate
to verify persistence. Automated tests never contact Expo or send a real alert.
| Symptom | Check |
|---|---|
| Token acquisition fails | Build credentials, EAS project ID, permission and supported device environment |
| Subscription returns 401 | Identity token/session and authentication configuration |
| Subscription returns conflict | Installation/registration already active under another recipient; detach it first |
Send returns credentials |
Server Expo access token and platform credentials in the Expo project |
| Accepted ticket, no alert | Receipt, device notification settings, foreground presentation and app environment |
| Receipt is not found yet | Poll later; do not resend solely because it is absent |
unregistered |
Disable the matching registration; refresh from the client when it registers again |
HTTP 429 / rate_limited |
Respect Retry-After and budget throughput across all replicas |
| Notification delivery is skipped | Expired content, no frozen target, detached session or changed registration version |
Expo/mobile behavior can change independently of this repository. Use the official setup and send/receipt guides for current platform prerequisites. The Go APIs and limits above describe this repository's actual adapter.