#2501·nakama

Issue: Google subscription RTDN handler silently drops PAUSED, RESTARTED, ON_HOLD, IN_GRACE_PERIOD, and other non-mapped notification types

Author: jag-lotusCreated May 13, 2026Updated May 13, 2026

Description

RegisterSubscriptionNotificationGoogle callbacks are silently skipped for the majority of Google Play Real-Time Developer Notification (RTDN) subscription event types. Only 4 of the ~13 subscription notification types defined by Google (and exposed as constants in nakama-common/runtime) are ever forwarded to the plugin hook — the rest are dropped at the HTTP boundary with a 200 OK and the registered callback is never invoked.

This affects every Google Play subscription lifecycle event that is not PURCHASED/RENEWED/EXPIRED/CANCELED — most notably SUBSCRIPTION_PAUSED, SUBSCRIPTION_RESTARTED, SUBSCRIPTION_ON_HOLD, SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_REVOKED, SUBSCRIPTION_RECOVERED, SUBSCRIPTION_DEFERRED, SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED, SUBSCRIPTION_PENDING_PURCHASE_CANCELED, and SUBSCRIPTION_PRICE_CHANGE_UPDATED. Server-side application logic has no way to react to any of these events.

Root cause

In server/core_subscription.go, inside googleNotificationHandler, the inner switch on googleNotification.SubscriptionNotification.NotificationType only maps four Google subscription types onto runtime.NotificationType. Every other type — including all of the types listed above — falls through to the default arm, which returns http.StatusOK and exits without ever calling the registered subscription callback:

go
var notificationType runtime.NotificationType
switch googleNotification.SubscriptionNotification.NotificationType {
case runtime.GoogleSubscriptionPurchased:
    notificationType = runtime.IAPNotificationSubscribed
case runtime.GoogleSubscriptionRenewed:
    notificationType = runtime.IAPNotificationRenewed
case runtime.GoogleSubscriptionExpired:
    notificationType = runtime.IAPNotificationExpired
case runtime.GoogleSubscriptionCanceled:
    notificationType = runtime.IAPNotificationCancelled
default:
    w.WriteHeader(http.StatusOK)
    return
}

The full GoogleSubscriptionNotificationType enum (with all 13+ values) is already defined and exported in nakama-common/runtime/runtime.go, so the client/plugin side already has the vocabulary to describe these events — it is only the server-side dispatch that is incomplete.

Steps to Reproduce

  1. Configure a Google Play subscription with the Nakama server's RTDN endpoint registered as the Pub/Sub push target (or otherwise wired to deliver RTDN payloads to /v2/console/iap/notification/google).
  2. Register a Go plugin callback via initializer.RegisterSubscriptionNotificationGoogle(...) that logs every invocation.
  3. Purchase a subscription on a test device, then pause it from the Google Play subscription management UI.
  4. Observe Pub/Sub: Google delivers a SUBSCRIPTION_PAUSED (notification type 10) RTDN payload as expected.
  5. Observe Nakama logs and the plugin callback.

The same reproduction also applies to SUBSCRIPTION_RESTARTED (resume before the scheduled auto-resume), SUBSCRIPTION_ON_HOLD (failed renewal, awaiting payment recovery), SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_REVOKED (immediate refund/revocation), and all other non-mapped types.

Expected Result

The registered RegisterSubscriptionNotificationGoogle callback fires for every Google RTDN subscription notification type, with notificationType carrying enough information for the plugin to differentiate at minimum:

  • Paused / Restarted (so plugins can track paused state and re-activate on resume)
  • On-Hold / In-Grace-Period (so plugins can keep entitlement during recovery windows)
  • Revoked (so plugins can immediately remove entitlement on instant refund)
  • Recovered (so plugins can reactivate after a successful payment recovery from on-hold)

This could be exposed either by (a) extending the abstract runtime.NotificationType enum with new values, or (b) passing the original GoogleSubscriptionNotificationType through to the callback unchanged via the providerPayload *SubscriptionV2GoogleResponse argument (which would still be a behaviour change: today, the callback is not invoked at all for these types).

Actual Result

For any GoogleSubscriptionNotificationType other than Purchased, Renewed, Expired, or Canceled, the registered plugin callback is never invoked. The RTDN payload is ACKed back to Google with 200 OK, so Pub/Sub does not retry, but the plugin has no observation of the event at all — not even a "unknown notification type" hook to fall back on. There is no log line either, because the dispatch short-circuits before any logging in the registered callback can run.

The practical impact is that subscription state on the Nakama side gradually desyncs from the user's actual Play Store state. Users who pause a subscription remain "active" in the server's view until their existing expiryTime elapses; users who resume (SUBSCRIPTION_RESTARTED) before that time receive no event, so the server only realises the subscription is alive again on the next SUBSCRIPTION_RENEWED — i.e. up to one billing period later. Similar gaps apply to on-hold/in-grace-period (entitlement decisions during recovery) and revoked (immediate refunds).

Suggested Fix

Extend the switch in googleNotificationHandler (server/core_subscription.go) to map the remaining GoogleSubscriptionNotificationType values onto either existing or new runtime.NotificationType constants, and forward them to the registered callback. The minimum useful set of additional mappings is:

  • GoogleSubscriptionPaused → new IAPNotificationPaused
  • GoogleSubscriptionRestarted → new IAPNotificationRestarted
  • GoogleSubscriptionOnHold → new IAPNotificationOnHold
  • GoogleSubscriptionInGracePeriod → new IAPNotificationInGracePeriod
  • GoogleSubscriptionRevoked → existing IAPNotificationRefunded (it is the immediate-refund counterpart to the existing voided-purchase flow)
  • GoogleSubscriptionRecovered → existing IAPNotificationRenewed (semantics match: the subscription is once again paying and active)

A backwards-compatible alternative is to forward all non-fatal Google notification types under a single new IAPNotificationOther constant and rely on providerPayload.SubscriptionNotification.NotificationType for the precise sub-type, but that is less ergonomic for plugin authors.

The equivalent Apple-side handler does not have this gap (Apple's notification type mapping is much closer to complete in core_subscription.go), which makes the Google branch feel like an oversight rather than a deliberate design.

Context

This is a server-side issue (Nakama server's RTDN webhook handler). Client-side behaviour is not involved.

  • Unity
  • Unreal
  • Other (Go plugin / server runtime callback)

Your Environment

  • Nakama: 3.38.0 (also reproducible on master as of 2026-05-13 — the relevant switch in server/core_subscription.go is unchanged)
  • nakama-common: v1.45.0

References

  • server/core_subscription.gogoogleNotificationHandler, the relevant switch (still 4 cases as of master, May 2026).
  • nakama-common/runtime/runtime.goGoogleSubscriptionNotificationType constants (all 13+ values already defined and exported, including GoogleSubscriptionPaused, GoogleSubscriptionRestarted, GoogleSubscriptionOnHold, GoogleSubscriptionInGracePeriod, GoogleSubscriptionRecovered, GoogleSubscriptionRevoked, etc.).
  • Google Play RTDN reference: https://developer.android.com/google/play/billing/rtdn-reference