Blog

RTDN vs polling: reacting to subscription changes in seconds

August 27, 2026 · Tierux Team

If your only way to learn a subscription changed is to check it when the app opens, your entitlement state is only ever as fresh as the user's last session. A subscriber who cancels on a Tuesday and doesn't reopen the app until Friday keeps paid access in your system for three extra days — or worse, a payment failure that should suspend access silently doesn't, because nothing ever asked Google Play about it. Real-Time Developer Notifications (RTDN) exist to close that gap.

What polling actually gets you

Client-side polling means: on app open (or on some interval), your app calls your backend, your backend re-verifies the purchase token against the store's API, and entitlement state gets refreshed. This works, but only reactively — it can't tell you anything happened until the client shows up to ask. For a subscriber who churns quietly (cancels, then never opens the app again), polling never fires at all; your backend would keep the subscriber marked active until the underlying purchase actually expires, with no earlier signal.

What RTDN is

RTDN is a Google Cloud Pub/Sub topic. Google Play publishes a message to it every time a subscription's state changes — a purchase, a renewal, a cancellation, entry into a grace period, an account hold, a pause, a refund, an expiry. You configure a push subscription pointing at your webhook endpoint, and Play delivers each event as an HTTP POST, typically within seconds of the underlying event.

Tierux's RTDN processor maps each notification type to a concrete entitlement effect. A representative slice of that mapping:

switch (notificationType) {
  case 1: // SUBSCRIPTION_RECOVERED
  case 2: // SUBSCRIPTION_RENEWED
  case 4: // SUBSCRIPTION_PURCHASED
  case 7: // SUBSCRIPTION_RESTARTED
    return { status: 'active', active: true, winback: 'clear' };
  case 3: // SUBSCRIPTION_CANCELED — auto-renew off; access remains until expiry
    return { status: 'cancelled', winback: 'set' };
  case 5: // SUBSCRIPTION_ON_HOLD
    return { status: 'account_hold', active: false };
  case 6: // SUBSCRIPTION_IN_GRACE_PERIOD — still has access
    return { status: 'grace_period', active: true };
  case 12: // SUBSCRIPTION_REVOKED — immediate revoke (refund/chargeback)
    return { status: 'cancelled', active: false };
  case 13: // SUBSCRIPTION_EXPIRED
    return { status: 'expired', active: false, winback: 'clear' };
}

Note the deliberate nuance here: a cancellation (type 3) does not immediately revoke access — the subscriber paid for the current period, so active is left untouched and access lapses naturally at expiry. A grace period (type 6) is also still active, giving the subscriber a window to fix a failed payment before losing access. A revoke (type 12, a refund or chargeback) is the one that cuts access immediately. Getting these distinctions right in real time, rather than approximating them on next app open, is the entire value of the notification path.

Why the JWT verification step matters

Because Pub/Sub push delivers to a public HTTP endpoint, anyone who finds that URL could, in principle, POST a fake notification claiming a subscription just renewed. Tierux verifies the identity of the caller before trusting the payload, using Google's own ID-token verification against the configured audience:

const makePubSubJwtVerifier = (serviceAccount?: string): PubSubJwtVerifier =>
  async (token, audience) => {
    const ticket = await sharedOAuth2Client.verifyIdToken({ idToken: token, audience });
    if (serviceAccount) {
      const payload = ticket.getPayload();
      if (!payload?.email_verified || payload.email !== serviceAccount) {
        throw new Error('email claim mismatch');
      }
    }
  };

Pub/Sub push subscriptions attach a signed OIDC token to every delivery. Verifying it against Google's public keys and your configured audience confirms the request genuinely came from Pub/Sub; optionally pinning the expected service-account email closes the gap further. A request that fails this check never reaches the notification-processing logic at all.

Idempotency: the part everyone underestimates

Pub/Sub guarantees at-least-once delivery, not exactly-once — the same notification can and will arrive more than once. If your handler naively re-applies every notification, a duplicate delivery of a stale event could clobber a newer state. RTDN processing needs two guards: a message-id check for exact duplicates, and an event-ordering check (using the notification's own timestamp) so a late-arriving but older event can't overwrite something more recent:

if (messageId === existing.lastRtdnMessageId) {
  return { outcome: 'duplicate_message' };
}
if (eventTimeMillis !== null && existingEventTimeMillis !== null
    && eventTimeMillis <= existingEventTimeMillis) {
  return { outcome: 'stale_event' };
}

Both guards are stored alongside the entitlement itself, so reprocessing the exact same message twice — whether from a genuine Pub/Sub redelivery or a retried webhook after a transient failure — is always safe. This is also why a temporary failure (say, the Play API is briefly unavailable while re-querying an updated expiresAt) can return a 503 and let Pub/Sub redeliver with backoff, instead of needing custom retry infrastructure: reprocessing is idempotent by construction.

Polling still has a place

None of this replaces verification entirely — the initial purchase still has to be verified against the store the first time, and a client-triggered refresh is still useful as a backstop. What RTDN changes is the default latency of everything that happens after that first verification: seconds instead of "whenever the user next opens the app." For a grace period specifically, that gap is the difference between catching a failed payment while there's still time to fix it, and finding out only after access has already lapsed.

Related reading

RTDN setup docs

How to configure the Pub/Sub topic and point Tierux's RTDN endpoint at it.

RTDN webhooks feature page

What Tierux does with each notification type, end to end.

linkedPurchaseToken: how plan changes really work

Plan changes are one of the trickiest RTDN scenarios — the notification carries a brand-new token with no local record yet.

Get subscription events within seconds, not sessions

Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.

Start free