BUILT INTO EVERY GOODSPEED APP
Push Receipt Polling
A 5-minute cron Edge Function polls Expo's getReceipts API and settles every push delivery row to ok/error/expired, automatically purging dead device tokens so the push_tokens table never silently accumulates invalid entries.
- Tier: Common
- Status: Config-toggled
- Config: features.pushNotifications.enabled
WHY IT MATTERS
When your app calls Expo's push delivery API, you receive a ticket ID immediately. That ID is only a receipt for the message being queued, not evidence of delivery. The actual outcome, whether the device received the notification, whether the push token has gone stale, or whether APNs or FCM rejected the message, is recorded separately on Expo's side and only becomes available through a second API call. Most apps never make that call. The result is a push_tokens table that silently fills with tokens belonging to uninstalled apps or wiped devices, a push_deliveries table frozen in pending state for months, and no reliable signal about whether notifications are reaching users or disappearing at the carrier layer.
The GAS template solves this with check_push_receipts, a dedicated Supabase Edge Function that runs on a 5-minute pg_cron schedule. On each tick it fetches all pending push_deliveries rows older than a configurable minimum age, batches their receipt IDs, queries Expo's getReceipts endpoint, and settles each row to one of three terminal states: ok (delivered), error (rejected with a reason code), or expired (not resolved within the retention window). When Expo returns a DeviceNotRegistered error code for a token, the function deletes that token from push_tokens in the same run. Tokens are pruned continuously, delivery outcomes are observable in a single SQL query, and the operational surface you own is an Edge Function with fewer than 250 lines of tested TypeScript.
HONEST LIMITS
When Push Receipt Polling is the wrong choice
Push receipt polling is unnecessary for apps sending fewer than roughly 100 push notifications per day. At that volume, the Expo dashboard surfaces delivery failures interactively, and the overhead of running a cron function, maintaining the push_deliveries schema, and handling receipt pagination is not justified by the operational signal it returns. The base push-notifications feature is sufficient at low volume. Apps that already run a server-side job queue or background worker infrastructure may prefer to integrate receipt polling into that system rather than running a separate cron Edge Function. The handler logic in handler.ts accepts a pre-built Supabase service client and a config object, so it can be called from any server-side runtime without the Deno wrapper. In that case, disable the cron trigger in cron.json and call handleCheckPushReceipts from your own scheduler.
Tier: Common · Config-toggled
Evaluate your use case
Check whether push receipt polling aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.pushNotifications.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes push receipt polling. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Push Receipt Polling capability breakdown
Concrete dimensions of what the built-in push receipt polling implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Storage backend | Data for this feature is managed by Supabase Postgres (push_deliveries + push_tokens tables). | Supabase Postgres (push_deliveries + push_tokens tables) |
| Network mode | This feature operates in Online-only mode. | Online-only |
| Sync frequency | Data syncs Cron (5-minute interval). | Cron (5-minute interval) |
| Tier | Common feature — config-toggled. | Common |
| Template status | Enabled via features.pushNotifications.enabled. | Config-toggled |
COMMON QUESTIONS
Do I need a separate Expo account or plan to use the receipts API?
No. The Expo push receipts API is part of the standard Expo Push Notification Service and is available on all plans, including the free tier. The template calls the standard https://exp.host/--/api/v2/push/getReceipts endpoint. The only credential required is the push token the device registered when the user granted notification permission. There is no additional API key, no paid tier gate, and no service agreement beyond Expo's standard terms. The receipt retention window, how long Expo holds a receipt before it expires, is controlled by Expo's infrastructure and is typically one to two days. This is why the template settles rows within a short window and marks anything outside that window as expired rather than polling indefinitely for delivery confirmation.
How do I see which notifications failed to deliver and why?
Every push notification the template sends inserts a row into push_deliveries with a status of pending and the Expo ticket ID. After the receipt polling cron runs, each row is updated to a terminal status: ok, error, or expired. Error rows store the error_code and error_message columns populated from Expo's receipt response. To query failed deliveries, run a SELECT on push_deliveries WHERE status is error. Common error codes include DeviceNotRegistered meaning the app was uninstalled or the token was revoked, MessageTooBig meaning the payload exceeded the platform size limit, and MessageRateExceeded meaning too many sends to a single device in a short window. The admin dashboard built into the GAS template surfaces these delivery counts without requiring a manual SQL query.
What happens if the cron function fails mid-run?
The handler is designed for safe partial completion. Rows that were fetched but not yet settled remain in pending status. On the next cron tick those same rows are fetched again because the query selects all rows with status pending older than the minimum age threshold. Expo receipt IDs are idempotent: querying the same receipt ID multiple times returns the same result until the receipt expires. Batch-level Expo API failures are caught and skipped, so a transient Expo outage causes rows to stay pending rather than being incorrectly marked as errors. The function uses retryWithBackoff for the Expo fetch call, handling short-duration 5xx responses with two automatic retries before giving up on a batch. Any unhandled exception is captured by the Sentry integration and surfaced in your error dashboard.
Can I change how often the receipt poller runs or set a longer expiry window?
Yes, both are configurable without touching the function code. The cron schedule is defined in supabase/functions/check_push_receipts/cron.json using standard pg_cron syntax. Changing the interval from five minutes to fifteen minutes requires updating a single string in that file. The expiry window, how long a receipt must be unresolved before it is marked as expired, is controlled by the RECEIPT_POLL_EXPIRE_MINUTES environment variable read in index.ts. The minimum age threshold before querying Expo is controlled by RECEIPT_POLL_MIN_AGE_MINUTES. Both variables are read at runtime via Deno.env.get, so they can be updated in the Supabase project dashboard under Edge Function secrets without redeploying the function.
GET IT BUILT INTO YOUR APP