RTDN and App Store Server Notifications
Store webhooks keep entitlement state current between purchases — and idempotency and ordering guards keep duplicate or out-of-order delivery from corrupting it.
Why polling doesn't scale
The naive way to keep subscription state fresh is a cron job: walk every active subscriber and re-query the store API. It works on day one and degrades from there. The work grows with your subscriber count, not with the number of things that actually changed — most cycles re-fetch state that is byte-identical to what you already stored. Store APIs are quota-bounded, so a growing base eventually forces you to stretch the interval, which is exactly the wrong direction: the interval is your staleness window. A cancellation, a refund, or a failed renewal is invisible until the next sweep reaches that user.
Both stores solve this by pushing. Google Play emits Real-Time Developer Notifications (RTDN) to a Cloud Pub/Sub topic; Apple posts App Store Server Notifications v2 to an HTTPS endpoint you register. Your backend does work proportional to the number of real lifecycle events, and entitlement state converges within seconds rather than within a polling interval.
Push moves the hard part rather than removing it. A queue that guarantees at-least-once delivery will hand you the same event twice, and a sender that retries on failure will hand you an older event after a newer one. That is what the rest of this chapter is about. For the broader argument for verifying purchases on your server at all, see the server-side purchase verification guide; for the setup mechanics of the Google Play path, see the RTDN docs.
What idempotency and ordering guards protect against
Duplicate delivery
Pub/Sub delivery is at-least-once, and Apple redelivers a notification when your endpoint does not return success. So the same signed payload can arrive two, three, or ten times. Re-applying DID_RENEW to an entitlement is harmless on its own — the row ends up in the same state — but the side effects are not idempotent by nature: each pass would append another renewal entry to the billing timeline, and that timeline feeds churn and revenue analytics.
Tierux records the delivery's own identity on the entitlement row and treats a repeat as a no-op. On the Google Play path that identity is the Pub/Sub messageId; on the Apple path it is the notification's notificationUUID — the two guards work the same way, but the notification types and coverage behind them are not identical between stores, so treat this as "both paths dedupe," not "both paths are at parity" (see the platform support matrix for what actually differs). A notification whose identity matches the one already stored short-circuits with a duplicate outcome before any write, and — because the same key is also armed inside the entitlement store's update transaction — a delivery that slips past the pre-check still loses the write and skips the timeline append. Each lifecycle event is recorded exactly once.
Out-of-order delivery
Ordering is the more dangerous failure. Neither store guarantees that a retry of an older event arrives before a newer one, so a redelivered SUBSCRIPTION_RENEWED can land after the SUBSCRIPTION_EXPIRED that superseded it. Applied naively, last-write-wins hands a lapsed subscriber a live entitlement.
Every notification carries a timestamp — eventTimeMillis on the Google Play path, signedDate on the Apple one — and Tierux stores the newest one it has applied for that entitlement. An event whose timestamp is less than or equal to the stored one is dropped as stale. As with the dedup key, the comparison is armed inside the store's update transaction, not merely checked beforehand, so two deliveries processed concurrently cannot race each other into a last-write-wins outcome. When no notification timestamp has been recorded yet, the guard falls back to the entitlement's last verification time. On the Google Play path that fallback only engages for plausible wall-clock values, so a test fixture can't poison the ordering baseline; the Apple path's fallback does not yet apply that same lower-bound check.
Retry semantics
Guards are only half of it — the response code decides whether the sender tries again. Tierux distinguishes a transient failure from a definitive one. A database blip mid-process returns 503 on either path. On the Google Play path a Play Developer API outage or expired credentials during the state-restoring re-query also returns 503; the Apple path does no store-API re-query at all (expiry comes from the signed payload), so that particular failure mode is Google-Play-only. Either way, Pub/Sub and Apple both redeliver a 503 with backoff, and reprocessing is safe precisely because the guards above make it a no-op if it already landed. Everything else — an unrecognized package, a purchase this backend never recorded, an informational notification type — acknowledges with a success code so the sender stops retrying an event that will never apply. A bad signature is rejected outright rather than retried: it is a forged or corrupt payload, not a transient fault.
# Outcomes that end in "we deliberately did nothing" duplicate / duplicate_message same messageId / notificationUUID already applied stale_event event timestamp <= the newest one already applied product_mismatch the store's product for this purchase differs from the recorded one — fail closed, never apply superseded_purchase (Google Play) an event about a purchase token the entitlement no longer points at, after a plan change # Outcomes that change state or ask for redelivery updated entitlement patched, timeline event appended once transient_error / 503 retryable — redelivered with backoff, guards make the reprocess safe
The two notification paths, side by side
Tierux processes lifecycle events on both stores: Google Play RTDN is the primary, production-hardened path, and Apple App Store Server Notifications are implemented with limitations. The support matrix at platform support carries the per-feature status for both.
Google Play — Real-Time Developer Notifications
Google Play publishes to Cloud Pub/Sub, which pushes into Tierux's backend. Tierux operates one shared topic for every customer app, so you point Play Console at it and create no GCP resources of your own; incoming notification JWTs are validated against Tierux's audience claim and the expected Google Play dispatcher service account. Setup detail lives in the RTDN docs.
An RTDN carries only a package name and a purchase token — no user id — so processing resolves the chain package to app, token hash to recorded purchase, product mapping to entitlement, and no-ops on any missing link, because the webhook is global and will receive events for purchases this backend never recorded. Notification types map to entitlement status (cancellation keeps access until expiry; grace period keeps access; account hold and pause revoke it), and for the state-restoring types — purchased, renewed, recovered, restarted — Tierux re-queries the Play Developer API for an authoritative expiry rather than trusting the notification's own view. If Play returns a different product for that token than the one recorded, the event is rejected rather than applied. When Play reports that a subscriber changed plans, the resulting notification is handled explicitly: the new token is resolved back to the superseded one, and later events on the old token can no longer mutate the re-bound entitlement. (This is notification handling for a plan change the store already recorded — Tierux does not yet expose an API to initiate one; see the platform support matrix.)
Apple App Store — Server Notifications v2
Apple's path is not a footnote to this one, and it is not equivalent to it either: App Store Server Notifications are implemented with limitations in the platform support matrix — the same transactional ordering and idempotency guards described above, but less production-hardened than the RTDN path.
Apple posts signed JWS notifications to a per-app endpoint, /api/v1/apple/assn/:appId. Tierux verifies Apple's signature via Apple's own App Store server library, then resolves the purchase by originalTransactionId — the stable identifier across the subscription's life, since each renewal carries a fresh transaction id. The notification type and subtype drive the entitlement effect: a renewal extends access; auto-renew turned off marks the entitlement cancelled while access runs to expiry; a failed renewal keeps access when Apple signals a billing grace period and suspends it when it does not; expiry, refund, and revocation end access; a reversed refund restores it; a developer-granted renewal extension moves the expiry out. Expiry itself is read from the signed payload, using the grace-period expiry when the subscriber is in grace or on hold.
The limitations, stated plainly and current as of the platform support matrix:
- Status is "implemented with limitations", not parity. The path carries the same transactional ordering and idempotency guards as RTDN —
signedDateandnotificationUUIDarmed inside the entitlement-store transaction — but remains less battle-tested at scale than the Google Play path. - Pointing Apple at the endpoint is a manual App Store Connect step. The App Store Connect API does not expose it, so the
setup_app_storeMCP guide walks you through entering the URL — Production and Sandbox both point at the same one. - Verified coverage is the handshake and renewal path. Apple's TEST handshake and a renewal refresh are exercised end to end through the real JWS decode path; broader hardening at scale is still ahead of this path, and the Apple verification path more generally has had less production hardening than the Play equivalent.
- Some Play-native features have no App Store counterpart at all. Cancellation-intercept win-back offers and pay-what-you-want tier pricing are not implemented for the App Store — see platform support for the full per-feature list.
| Notification path | Google Play RTDN | Apple App Store Server Notifications |
|---|---|---|
| Overall status | Yes — implemented (RTDN + Pub/Sub JWT) | Yes, with limitations — implemented, less production-hardened than the RTDN path |
| Transport | Cloud Pub/Sub push to a shared Tierux topic; OIDC JWT validated | HTTPS POST of a signed JWS to /api/v1/apple/assn/:appId |
| Purchase identity in the payload | Package name + purchase token (no user id — resolved via the recorded purchase) | originalTransactionId (stable across renewals) |
| Duplicate guard | Pub/Sub messageId, armed in the entitlement-store transaction |
notificationUUID, armed in the same transaction |
| Ordering guard | eventTimeMillis vs the newest applied event time |
signedDate vs the newest applied event time |
| Expiry source | Re-query of the Play Developer API on state-restoring types | Read from the signed notification payload; no additional server-API re-query on this path |
| Transient failure handling | 503 — Pub/Sub redelivers; reprocessing is guarded |
503 — Apple redelivers with backoff; reprocessing is guarded |
| Endpoint setup | Point Play Console at Tierux's shared Pub/Sub topic | Manual App Store Connect URL entry, guided by the setup_app_store MCP tool |
Per-feature, per-platform status is maintained in the platform support matrix.
Docs: RTDN setup
Pointing Play Console at the shared Pub/Sub topic, JWT validation, and the notification-type mapping.
Docs: Webhooks
Forwarding the same lifecycle events to your own backend as signed HTTP POSTs.
Glossary: RTDN
What Real-Time Developer Notifications are and which events Google Play emits.
Glossary: App Store Server Notifications
Apple's signed server-to-server messages, and how they differ from RTDN.
Server-side purchase verification
Why the client can't be trusted to report a purchase, and how verification works on both stores.
Platform support matrix
The canonical per-feature status for Google Play and Apple App Store.
Developers
Entitlements in one REST call, set up by your AI agent over MCP — the home base for the API this guide is written against.
Entitlements in one REST call
Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.
Start free