Skip to content
Skip to content
Goodspeed

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

  1. Evaluate your use case

    Check whether push receipt polling aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. 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.

  3. 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.

ItemDescriptionStrength
Storage backendData for this feature is managed by Supabase Postgres (push_deliveries + push_tokens tables).Supabase Postgres (push_deliveries + push_tokens tables)
Network modeThis feature operates in Online-only mode.Online-only
Sync frequencyData syncs Cron (5-minute interval).Cron (5-minute interval)
TierCommon feature — config-toggled.Common
Template statusEnabled 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

Score your idea and get push receipt polling wired in from day one