Real Time Developer Notifications (RTDN)
RTDN pushes subscription and purchase state changes from Google Play to your backend within seconds. Use it as the primary signal for entitlement updates; polling the Play Developer API on a schedule is a fallback, not a substitute.
Phase 1: Understand
Pin down the following before you write code:
- What triggers a notification. Google Play publishes a message to a Cloud Pub/Sub topic you own every time a subscription changes state, a purchase is voided, or (if enabled in the RTDN settings) a one time product is purchased or a pending one is canceled.
- What the notification contains. A
DeveloperNotificationJSON object withpackageName,eventTimeMillis, and exactly one ofsubscriptionNotification,oneTimeProductNotification,voidedPurchaseNotification,pendingRefundReviewNotification, ortestNotification. Subscription, one time product, and voided purchase notifications carry apurchaseToken(a pending refund review carriesorderIdandpendingRefundTokeninstead), not the full purchase state. - Who owns what. You own the Pub/Sub topic and subscription in your own Google Cloud project. Google Play only has publish rights.
- Delivery semantics. Cloud Pub/Sub guarantees at least once delivery and does not guarantee ordering. Duplicates and out of order messages are normal.
- The golden rule. RTDNs are signals, not sources of truth. For every notification, call
purchases.subscriptionsv2.getorpurchases.products.getand update entitlements from the API response, not the notification type.
Decide push vs pull:
| Strategy | Pick when |
|---|---|
| Push subscription | You have a public HTTPS endpoint and want a webhook style integration with retries handled by Pub/Sub |
| Pull subscription | Your backend has no public URL, needs batch processing, or wants fine grained control over consumption rate |
Push is the simpler default. You can swap strategies later without changing the Play Console configuration.
Phase 2: Plan
Write the plan before provisioning anything.
Infrastructure checklist
- One Pub/Sub topic (example:
play-billing-notifications). One topic handles subscriptions, one time products, and voided purchases. - One subscription attached to the topic with an appropriate
ack-deadline(60s is a reasonable starting point). - IAM binding granting
roles/pubsub.publishertogoogle-play-developer-notifications@system.gserviceaccount.comon the topic. - Play Console Monetization setup pointing at
projects/YOUR_PROJECT_ID/topics/play-billing-notifications. - A test notification fired from the Play Console to confirm end to end wiring.
Ack semantics
Your webhook must return 2xx within the ack deadline to acknowledge the message. Any other response or a timeout causes Pub/Sub to retry with exponential backoff. Plan accordingly:
- Ack as soon as you have durably captured the message (message ID plus payload). Do the API call and database updates inside the same request only if they fit well inside the ack deadline. Otherwise ack fast and process asynchronously from a queue you own.
- Never ack before you have persisted enough to replay the work. A lost message after ack means a missed state change.
- Any status other than 102, 200, 201, 202, or 204 (4xx included) is a negative acknowledgement, and Pub/Sub redelivers the message. Return
5xxfor transient failures. For a message you can never process, save it to your own error store and return200, or configure a dead letter topic with a maximum delivery attempts limit.
Idempotency plan
Because duplicates and out of order deliveries are guaranteed, every handler must be idempotent.
- Deduplicate on Pub/Sub
messageId. Persist processed message IDs with a TTL of 24 to 48 hours. Skip any replay you have already seen, then ack. - Do not mutate state from the notification type alone. Always call the Developer API for the current state. Two out of order notifications produce two API calls that both return the same current state, so your database converges to the correct value.
- Track
eventTimeMillisperpurchaseToken. Record the highest event time you have seen so you can detect stale events if you need to branch logic, but still call the API on every notification. - Make database writes idempotent. Use upserts keyed on
purchaseTokenrather than inserts. Revoking access must be safe to run twice; so must extending expiry.
Monitoring
- Dashboard the count of unacked messages and the oldest unacked age on the subscription.
- Alert on sustained non 2xx responses from the webhook.
- Count notifications by type as a metric so you can spot anomalies (for example, a spike in
SUBSCRIPTION_ON_HOLD).
Phase 3: Execute
Step 1: Create the topic
bashgcloud pubsub topics create play-billing-notifications
Step 2: Grant publish rights to Google Play
bashgcloud pubsub topics add-iam-policy-binding \ play-billing-notifications \ --member=serviceAccount:google-play-developer-notifications@system.gserviceaccount.com \ --role=roles/pubsub.publisher
Missing this binding is the most common reason a freshly configured RTDN pipeline delivers no messages.
Step 3: Create a push subscription
bashgcloud pubsub subscriptions create play-billing-sub \ --topic=play-billing-notifications \ --push-endpoint=https://api.yourapp.com/billing/rtdn \ --ack-deadline=60
For pull, omit --push-endpoint and run a subscriber loop in your backend.
Step 4: Enable RTDN in Play Console
In the Play Console, open Monetization setup, find Real time developer notifications, check Enable real-time notifications, enter projects/YOUR_PROJECT_ID/topics/play-billing-notifications, click Send Test Message, choose Get all notifications for subscriptions and one-time products under notification types (the other option omits one time product events), and click Save changes. A push subscriber should receive the test within seconds.
Step 5: Write the webhook endpoint
The push body wraps the notification in a Pub/Sub envelope. The data field is a base64 encoded JSON string.
json{ "message": { "data": "eyJ2ZXJzaW9uIjoiMS4wIiwi...", "messageId": "136969346945", "attributes": {} }, "subscription": "projects/myproject/subscriptions/play-billing-sub" }
Decode the payload:
kotlinimport java.util.Base64 fun decodePubSubData(base64: String): String { val bytes = Base64.getDecoder().decode(base64) return String(bytes, Charsets.UTF_8) }
Parse into a DeveloperNotification:
kotlindata class DeveloperNotification( val version: String, val packageName: String, val eventTimeMillis: Long, val subscriptionNotification: SubscriptionNotification? = null, val oneTimeProductNotification: OneTimeProductNotification? = null, val voidedPurchaseNotification: VoidedPurchaseNotification? = null, val pendingRefundReviewNotification: PendingRefundReviewNotification? = null, val testNotification: TestNotification? = null )
Step 6: Dispatch by notification shape
Route by which sub object is present, then by type code.
kotlinfun route(n: DeveloperNotification) { when { n.subscriptionNotification != null -> handleSubscription(n.subscriptionNotification) n.oneTimeProductNotification != null -> handleOneTime(n.oneTimeProductNotification) n.voidedPurchaseNotification != null -> handleVoided(n.voidedPurchaseNotification) n.pendingRefundReviewNotification != null -> handleRefundReview(n.pendingRefundReviewNotification) n.testNotification != null -> log("RTDN test received") else -> log("Unknown RTDN shape: $n") } }
Call orders.reviewrefund (POST applications/{packageName}/orders/{orderId}:reviewrefund) with pendingRefundToken, refundPreference, and sampleContentProvided within 24 hours of a pendingRefundReviewNotification.
Step 7: Deduplicate before processing
kotlinsuspend fun onPubSubMessage( messageId: String, n: DeveloperNotification ) { if (store.hasProcessed(messageId)) return route(n) store.markProcessed(messageId) }
Step 8: Always call the API
kotlinsuspend fun handleSubscription(s: SubscriptionNotification) { metrics.increment("rtdn.sub.${s.notificationType}") val sub = playApi.getSubscriptionV2( packageName, s.purchaseToken ) updateEntitlementFromApiState(s.purchaseToken, sub) }
Apply the same pattern to one time products (purchases.products.get) and voided purchases (revoke the entitlement keyed on the purchase token; for refundType 2 claw back only the refunded quantity, see below).
Notification type reference
Chapter 10 of the handbook documents the 14 subscription types, 2 one time product types, voided purchase, and test notification. The 18 subscription notification types (codes 1 to 13, 17 to 20, and 22; the newer ones are SUBSCRIPTION_ITEMS_CHANGED, SUBSCRIPTION_CANCELLATION_SCHEDULED, SUBSCRIPTION_PRICE_CHANGE_UPDATED, SUBSCRIPTION_PENDING_PURCHASE_CANCELED, and SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED) are listed in Appendix A of the handbook. See the RTDN chapter on revenuecat.com for in depth coverage of each type; see the rtdn-reference skill or the appendix linked from it for the complete list.
Key subscription types you must handle correctly:
| Type | Code | Action |
|---|---|---|
| SUBSCRIPTION_RECOVERED | 1 | Restore access; payment recovered from hold |
| SUBSCRIPTION_RENEWED | 2 | Extend expiry to new lineItems[].expiryTime |
| SUBSCRIPTION_CANCELED | 3 | Keep access until lineItems[].expiryTime; do not revoke now |
| SUBSCRIPTION_PURCHASED | 4 | Server side safety net; verify the purchase |
| SUBSCRIPTION_ON_HOLD | 5 | Revoke access; keep the subscription record |
| SUBSCRIPTION_IN_GRACE_PERIOD | 6 | Keep access; surface a payment update prompt |
| SUBSCRIPTION_RESTARTED | 7 | User resubscribed before expiry; confirm auto renewing |
| SUBSCRIPTION_PRICE_CHANGE_CONFIRMED | 8 | Deprecated; handle code 19 instead |
| SUBSCRIPTION_DEFERRED | 9 | Fetch new expiry time and extend access |
| SUBSCRIPTION_PAUSED | 10 | Revoke access until resume |
| SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED | 11 | Sync pause schedule with API state |
| SUBSCRIPTION_REVOKED | 12 | Revoke access immediately |
| SUBSCRIPTION_EXPIRED | 13 | Terminal; revoke if not already |
| SUBSCRIPTION_PRICE_CHANGE_UPDATED | 19 | Re-read lineItems[].autoRenewingPlan.priceChangeDetails |
| SUBSCRIPTION_PENDING_PURCHASE_CANCELED | 20 | Clean up the pending record. If it was a pending upgrade, downgrade, or top up, the old subscription stays active |
One time products: ONE_TIME_PRODUCT_PURCHASED (1) and ONE_TIME_PRODUCT_CANCELED (2). Voided purchases: revoke and record for reconciliation; productType marks subscription (1) vs one time (2). For refundType 2 (quantity based partial refund) claw back only the refunded quantity (refundableQuantity from purchases.productsv2 holds what remains).
Phase 4: Verify
- Fire a test notification from Play Console and confirm the webhook logs a
testNotificationpayload. - Run an end to end sandbox purchase, cancel it, and verify you receive
SUBSCRIPTION_PURCHASEDthenSUBSCRIPTION_CANCELEDwith matching purchase tokens. - Replay the same Pub/Sub
messageIdtwice and confirm your handler runs the business logic exactly once. - Shuffle two notifications for the same token and confirm the final database state matches the API response rather than the order of delivery.
- Temporarily return
500from the webhook and confirm Pub/Sub retries and eventually delivers after you return to200. - Check the Pub/Sub subscription metrics for oldest unacked age; it should stay close to zero in steady state.
- Run a periodic reconciliation job that polls a sample of subscriptions against the API. If it finds drift, your RTDN pipeline has gaps to investigate.


