InstaWebhook
September 14, 2026By InstaWebhook TeamRetries and Replay

Mission-Critical Mobile IAP: Handling Apple App Store & Google Play Notifications with Zero Data Loss

Mission-Critical Mobile IAP: Handling Apple App Store & Google Play Notifications with Zero Data Loss In mobile app development, handling in-app purchases (IAPs) and auto-renewable...

Android billing webhooksApple App Store server-to-server notificationsApple App Store webhooksApple JWS notification decodingApp Store billing notificationsApp Store notification typesApp Store Server Notifications V2automated IAP entitlement updatesdead letter queue for webhooksdurable webhook pipelinesGoogle Cloud PubSub IAP webhooksGoogle Play Billing Library S2SGoogle Play billing webhooksGoogle Play billing webhooks architectureGoogle Play developer notificationsGoogle Play Pub Sub notificationsGoogle Play RTDN setupGoogle Play subscription notificationsGoogle RTDNhandling Apple App Store notificationshandling App Store refundshandling Google Play grace periodshandling IAP cancellationshandling S2S notificationsIAP backend architectureIAP backend queue workerIAP refund webhooksidempotent S2S notification processingin-app purchase webhooksin-app subscription lifecycle eventsiOS in-app purchase S2Smobile app monetization backendmobile backend developmentmobile IAP event processingmobile IAP webhooksmobile in app purchase server notificationmobile subscription backend infrastructuremobile subscription managementmobile subscription webhookspreventing subscription revenue lossReal-Time Developer Notificationsreliable webhook ingestionS2S notification signature verificationS2S notification verificationS2S webhook failure handlingS2S webhook processingserver-to-server billing syncStoreKit 2 server notificationsStoreKit server-to-server notificationssubscription entitlement syncsubscription revenue loss preventionsubscription status updateswebhook queue architecturewebhook replay pipelinewebhook retry logic
Mission Critical Mobile IAP Handling Apple App Store Google Play Notifications With Zero Data Loss

Mission-Critical Mobile IAP: Handling Apple App Store & Google Play Notifications with Zero Data Loss

In mobile app development, handling in-app purchases (IAPs) and auto-renewable subscriptions on the client side is a recipe for revenue leakage. Mobile devices lose connectivity, users force-close apps mid-transaction, and malicious actors attempt local receipt tampering. To maintain a source of truth for user entitlements, modern mobile engineering architectures rely on server-to-server (S2S) event streams: Apple App Store Server Notifications V2 and Google Play Real-Time Developer Notifications (RTDN).

Both platforms have shipped meaningful changes to these systems recently — new notification types, new API deprecations, and (in Apple's case) new notification categories tied to age-verification laws and alternative-payment regulation. This guide has been updated and fact-checked against current Apple and Google developer documentation to reflect where things actually stand.

You'll learn how to parse and cryptographically verify incoming payloads, build an idempotent and fault-tolerant ingestion pipeline, avoid state corruption, and where the original DIY approach to signature verification falls short of what both platforms now recommend.


1. Comparing the Architectures: Apple ASN V2 vs. Google Play RTDN

Code example
┌─────────────────────────────────────────────────────────────────────────┐
│                           Mobile Store Backend                          │
└────────────────────────────────────┬────────────────────────────────────┘
                                      │
           ┌──────────────────────────┴──────────────────────────┐
           ▼                                                     ▼
┌───────────────────────┐                             ┌────────────────────┐
│   Apple App Store     │                             │    Google Play     │
│ (ASN V2 Push Hooks)   │                             │ (GCP Cloud Pub/Sub)│
└──────────┬────────────┘                             └─────────┬──────────┘
           │ Direct HTTP POST                                   │ Push / Pull
           │ (JWS Payload)                                      │ (Base64 JSON)
           ▼                                                     ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                  Ingestion Gateway (HTTP 200 Ack <50ms)                 │
└────────────────────────────────────┬────────────────────────────────────┘
                                      │
                                      ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                   Durable Message Queue (Kafka / SQS)                   │
└────────────────────────────────────┬────────────────────────────────────┘
                                      │
                                      ▼
┌─────────────────────────────────────────────────────────────────────────┐
│              Worker Pool: Verify → Resolve → Save → Sync                │
└─────────────────────────────────────────────────────────────────────────┘
DimensionApple App Store Server Notifications V2Google Play Real-Time Developer Notifications (RTDN)
Transport LayerDirect webhook HTTP POST from AppleGoogle Cloud Pub/Sub (push HTTP or pull client)
Payload FormatSigned compact JSON Web Signature (JWS)Base64-encoded JSON wrapper
Security MechanismX.509 certificate chain (x5c) chained to an Apple Root CAPub/Sub OIDC bearer token (Google-signed JWT), or GCP IAM for pull subscriptions
Payload NatureSelf-contained event: includes full transaction & renewal stateSignal event: notification only; payload requires a developer API lookup
Primary KeysoriginalTransactionId, transactionIdpurchaseToken, subscriptionId / sku
Retry Strategy5 fixed retries (not exponential) at 1, 12, 24, 48, and 72 hours after the previous attempt — production only; sandbox sends once with no retryCloud Pub/Sub retry policy (configurable dead-lettering & retention)

Correction to a common misconception: Apple's retry schedule is often described as "exponential backoff." It isn't — it's a fixed five-attempt schedule (1h → 12h → 24h → 48h → 72h after the previous attempt), meaning the full retry window spans roughly 157 hours (~6.5 days) from the first failure, not 72 hours total. Sandbox notifications are never retried, so a slow debugger breakpoint in sandbox testing can silently drop a notification for good.


2. Deep Dive: Apple App Store Server Notifications V2 (ASN V2)

Apple deprecated Version 1 notifications in favor of V2, which uses JWS (RFC 7515) tokens to cryptographically guarantee payload authenticity.

Payload Structure

When Apple POSTs to your endpoint, the HTTP body contains a top-level JSON object with a single signedPayload key:

Code example
{
  "signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlF...[truncated JWS]..."
}

Decoding this outer JWS reveals the root notification envelope:

Code example
{
  "notificationType": "DID_RENEW",
  "subtype": "BILLING_RECOVERY",
  "notificationUUID": "8c48a731-29e2-45e0-a4a3-7648348123bc",
  "data": {
    "appAppleId": 1234567890,
    "bundleId": "com.example.app",
    "bundleVersion": "1.0.0",
    "environment": "Production",
    "signedTransactionInfo": "eyJhbGciOiJFUzI1Ni...[signed JWS transaction]...",
    "signedRenewalInfo": "eyJhbGciOiJFUzI1Ni...[signed JWS renewal info]..."
  },
  "version": "2.0",
  "signedDate": 1773479000000
}

signedTransactionInfo and signedRenewalInfo are themselves distinct JWS structures that must be independently verified and decoded. Note also that data, summary, and externalPurchaseToken are mutually exclusive top-level fields — a given payload contains only one of them, depending on notificationType.

The Current notificationType Catalog

The original set of event types most integrations were built around (SUBSCRIBED, DID_RENEW, DID_FAIL_TO_RENEW, EXPIRED, REFUND, REVOKE) is still accurate, but it's now a subset of a considerably larger list:

CategorynotificationType values
Subscription lifecycleSUBSCRIBED, DID_RENEW, EXPIRED, DID_FAIL_TO_RENEW, GRACE_PERIOD_EXPIRED, DID_CHANGE_RENEWAL_STATUS, DID_CHANGE_RENEWAL_PREF
Refunds & disputesREFUND, REFUND_DECLINED, REFUND_REVERSED, CONSUMPTION_REQUEST
PricingPRICE_INCREASE, PRICE_CHANGE
Offers & renewal extensionsOFFER_REDEEMED, RENEWAL_EXTENDED, RENEWAL_EXTENSION
RevocationREVOKE
One-time purchasesONE_TIME_CHARGE
External / alternative paymentEXTERNAL_PURCHASE_TOKEN
Account & consent stateRESCIND_CONSENT, METADATA_UPDATE, MIGRATION
TestingTEST

Three of these are worth calling out specifically because they're recent and change how a backend needs to think about entitlements:

  • RESCIND_CONSENT — introduced alongside new U.S. state-level app-store age-verification and parental-consent laws (for example, Texas's App Store Accountability Act, which took effect January 1, 2026). It signals that consent tied to a minor's account has been withdrawn, and it can fire even for free apps with no originalTransactionId, which breaks account-mapping logic built solely around transaction identifiers.
  • EXTERNAL_PURCHASE_TOKEN — tied to StoreKit's External Purchase API, used by apps taking advantage of alternative payment options (relevant in the EU under the Digital Markets Act, and in other regions with similar external-link entitlements). This payload contains an externalPurchaseToken object instead of the usual data object, and reporting it back requires calling the External Purchase Server API's report endpoint.
  • ONE_TIME_CHARGE, METADATA_UPDATE, and MIGRATION were added alongside a broader 2025–2026 changelog push that also introduced revocationType/revocationPercentage fields on transactions (for partial refunds) and, as of April 2026, TransactionCommitmentInfo/billingPlanType fields to support monthly subscriptions sold with a 12-month commitment.

Practical implication: a REFUND notification no longer necessarily means "revoke 100% of entitlement immediately" — with partial refunds now represented via revocationPercentage, your reconciliation logic should check that field rather than assuming a full revocation.

Retry Behavior, Precisely

If the App Store doesn't receive a 200206 response, it retries up to five times at 1, 12, 24, 48, and 72 hours after the previous attempt — production only. Sandbox delivers once and does not retry on failure. Separately, CONSUMPTION_REQUEST notifications are not retries in the technical sense — Apple resends them periodically for the duration of an open refund request, each with a new notificationUUID, until the refund case closes.


3. Deep Dive: Google Play Real-Time Developer Notifications (RTDN)

Google Play streams billing events through Google Cloud Pub/Sub rather than issuing direct webhooks — you own the Pub/Sub topic and subscription.

Payload Structure

Your Pub/Sub push endpoint receives a Cloud Pub/Sub wrapper. The actual billing event is base64-encoded inside message.data:

Code example
{
  "message": {
    "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzczNDc5MDAwMDAwIiwic3Vic2NyaXB0aW9uTm90aWZpY2F0aW9uIjp7InZlcnNpb24iOiIxLjAiLCJub3RpZmljYXRpb25UeXBlIjo0LCJwdXJjaGFzZVRva2VuIjoiZmlwb2VwaWptZW1pamRtZ25hb3BtY2huLi4uIiwic3Vic2NyaXB0aW9uSWQiOiJwcmVtaXVtX21vbnRobHkifX0=",
    "messageId": "918237192837129",
    "publishTime": "2026-03-14T09:15:00.000Z"
  },
  "subscription": "projects/my-app-backend/subscriptions/play-billing-sub"
}

Decoded, message.data yields a DeveloperNotification envelope. As of the current reference documentation, that envelope can carry five mutually exclusive notification objects — not just the two (subscriptionNotification, oneTimeProductNotification) that older guides describe:

Code example
{
  "version": "1.0",
  "packageName": "com.example.app",
  "eventTimeMillis": "1773479000000",
  "subscriptionNotification": { "...": "..." },
  "oneTimeProductNotification": { "...": "..." },
  "voidedPurchaseNotification": { "...": "..." },
  "pendingRefundReviewNotification": { "...": "..." },
  "testNotification": { "...": "..." }
}

The Google Play Signal Pattern (Still True)

RTDN gives you the bare minimum: a purchaseToken, a product/subscription identifier, and an integer notificationType. It does not tell you the new expiration date, auto-renew state, or payment status. You must call the Google Play Developer API using the purchaseToken to get canonical state.

SubscriptionNotification — the Full Current Type List

The original 6-value list (recovered, renewed, canceled, on-hold, grace period, expired) only covers about a third of the current enum:

#TypeMeaning
1SUBSCRIPTION_RECOVEREDRecovered from account hold or resumed from pause
2SUBSCRIPTION_RENEWEDActive subscription renewed
3SUBSCRIPTION_CANCELEDVoluntarily or involuntarily canceled
4SUBSCRIPTION_PURCHASEDNew subscription purchased
5SUBSCRIPTION_ON_HOLDEntered account hold
6SUBSCRIPTION_IN_GRACE_PERIODEntered grace period
7SUBSCRIPTION_RESTARTEDUser restored a canceled-but-not-yet-expired subscription from Play > Account > Subscriptions
8SUBSCRIPTION_PRICE_CHANGE_CONFIRMEDDeprecated — use type 19 instead
9SUBSCRIPTION_DEFERREDRecurrence time extended
10SUBSCRIPTION_PAUSEDSubscription paused
11SUBSCRIPTION_PAUSE_SCHEDULE_CHANGEDPause schedule changed
12SUBSCRIPTION_REVOKEDRevoked before expiration (e.g., refund)
13SUBSCRIPTION_EXPIREDReached end of lifecycle
17SUBSCRIPTION_ITEMS_CHANGEDAn item in a subscription bundle changed
18SUBSCRIPTION_CANCELLATION_SCHEDULEDInstallment-subscription cancellation scheduled for end of commitment period
19SUBSCRIPTION_PRICE_CHANGE_UPDATEDA subscription item's price-change details were updated (replacement for type 8)
20SUBSCRIPTION_PENDING_PURCHASE_CANCELEDA pending transaction was canceled
22SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATEDConsent period for a required regional price step-up began, or consent was given

(Numbers 14–16 and 21 are currently unused/reserved in Google's published enum.)

One-Time Products, Voided Purchases, and Refund Review — Not Covered in Older Guides

These three notification families matter for any app selling consumables, not just subscriptions:

OneTimeProductNotification (only sent if you've opted in):

Code example
{
  "version": "1.0",
  "notificationType": 1,
  "purchaseToken": "purchase-token-string",
  "sku": "remove_ads"
}
  • 1 = ONE_TIME_PRODUCT_PURCHASED
  • 2 = ONE_TIME_PRODUCT_CANCELED (a pending purchase, e.g. a cash-based payment, was canceled before completing)

Confirm with purchases.productsv2.getproductpurchasev2 using the token.

VoidedPurchaseNotification — fired on refund, chargeback, or revocation:

Code example
{
  "purchaseToken": "purchase-token-string",
  "orderId": "GPA.1234-5678-9012-34567",
  "productType": 1,
  "refundType": 1
}

productType: 1 = subscription, 2 = one-time product. refundType: 1 = full refund, 2 = quantity-based partial refund (for multi-quantity purchases). This is a pull-friendly signal — you can also retrieve voided purchases directly on a schedule via the Voided Purchases API.

PendingRefundReviewNotification — a genuinely new operational category, sent when a user files a chargeback that requires developer input:

Code example
{
  "version": "1.0",
  "pendingRefundToken": "example-token",
  "orderId": "GPA.1234-5678-9012-34567",
  "refundReason": 7,
  "obfuscatedAccountId": "user-account-id",
  "obfuscatedProfileId": "user-profile-id"
}

You have a 24-hour SLA to call the ReviewRefund API with a refund suggestion and usage evidence. Today, refundReason only ever arrives as 7 (CHARGEBACK), but Google's own docs note your code should tolerate new reason codes appearing later. This deserves its own alert in your monitoring stack — missing the 24-hour window forfeits your ability to contest the chargeback.

API Deprecation You Need to Know About

purchases.subscriptions.get (the v1 endpoint) has been deprecated since May 21, 2025 and is scheduled to shut down August 31, 2027 (extendable to November 1, 2027 on request). The replacement, purchases.subscriptionsv2.get, has been the recommended endpoint for a while — if your reconciliation jobs or webhook handlers still call the v1 endpoint, migrate now rather than waiting for the shutdown date. subscriptions.refund and subscriptions.revoke are on the same deprecation timeline, replaced by subscriptionsv2.refund-equivalent flows and subscriptionsv2.revoke. A second, later wave — subscriptions.cancel and subscriptions.defer — was deprecated May 19, 2026, shutting down August 31, 2028.

Separately, if you're maintaining the client-side Play Billing Library integration that feeds these purchases: Billing Library version 8 or later has been required for all new apps and app updates since August 31, 2026 (extensions were available until November 1, 2026). If your Android client is still on an older Billing Library major version, this affects whether Play Store accepts your next app update, not just server-side behavior.


4. The Systemic Failure Modes of Mobile IAP Webhooks

1. The HTTP Timeout Bottleneck

Both platforms expect a fast acknowledgment. If your webhook handler synchronously verifies signatures, queries databases, and calls the Google Play Developer API before responding, you'll trigger retries that flood your server with duplicate deliveries.

2. Out-of-Order Delivery & Race Conditions

Webhooks guarantee at-least-once delivery, not ordered delivery. A user who cancels and immediately resubscribes can have their SUBSCRIBED event arrive after a stale CANCELED event, incorrectly revoking an active subscriber if you process strictly by arrival order.

3. Payload Fraud & Missing Cryptographic Verification

Accepting unverified payloads lets attackers fabricate renewals or bypass paywalls. On the Apple side this means validating the full x5c chain to an Apple Root CA (not just checking the signature algorithm). On the Google side, a default Pub/Sub push subscription sends no proof of origin at all — no signature header, no HMAC — unless you explicitly enable OIDC authentication on the subscription and verify the resulting bearer token.

4. Account Mapping Failures

originalTransactionId and purchaseToken represent the store transaction, not your internal user_id. Logged-out purchases or later account creation can orphan a transaction. This gets harder with RESCIND_CONSENT events, which can arrive with no transaction identifier at all for free apps.

5. Lack of Idempotency

Retries or network blips can deliver the same event twice. Without idempotent processing, you risk duplicate ledger entries, duplicate lifecycle emails, or inflated analytics.


5. Architectural Blueprint: Building a Resilient Processing Pipeline

Separate payload ingestion from event processing, and verify signatures using the platforms' own libraries rather than hand-rolled crypto.

Step 1: Secure & Fast Ingestion Gateway

Apple: use the official App Store Server Library, not manual JWS decoding.

A common pattern — manually pulling x5c out of the JWS header with jsonwebtoken, converting the leaf certificate to PEM, and calling jwt.verify against it — verifies the signature but skips validating that the certificate chain actually roots to an Apple Root CA, and skips revocation checking entirely. Apple explicitly recommends using its own libraries (available for Swift, Node.js, Python, and Java) instead, because SignedDataVerifier handles full chain validation against Apple's published root certificates and, optionally, online revocation checks:

Code example
import { SignedDataVerifier, Environment } from "@apple/app-store-server-library";
import * as fs from "fs";

// Download current root certs from https://www.apple.com/certificateauthority/
const appleRootCertificates = [
  fs.readFileSync("./certs/AppleRootCA-G3.cer"),
];

const verifier = new SignedDataVerifier(
  appleRootCertificates,
  true,                       // enableOnlineChecks: revocation + expiry checks
  Environment.PRODUCTION,
  "com.example.app",
  1234567890                  // appAppleId — required in production
);

export async function verifyAppleNotification(signedPayload: string) {
  // Verifies the outer envelope AND is used again per-field for
  // signedTransactionInfo / signedRenewalInfo, which are separately signed.
  return verifier.verifyAndDecodeNotification(signedPayload);
}

Google: verify the Pub/Sub push subscription's OIDC token — it's not optional.

Unlike Apple's self-contained signed payload, an unauthenticated Pub/Sub push endpoint accepts any POST body from anyone who discovers the URL. Enable authentication on the push subscription and verify the resulting Authorization: Bearer <JWT> on every request:

Code example
const { OAuth2Client } = require("google-auth-library");
const client = new OAuth2Client();

async function verifyPubSubPush(req) {
  const authHeader = req.headers["authorization"] || "";
  const bearerToken = authHeader.match(/Bearer (.*)/)?.[1];
  if (!bearerToken) throw new Error("Missing bearer token");

  const ticket = await client.verifyIdToken({
    idToken: bearerToken,
    audience: "https://your-ingestion-endpoint.example.com",
  });

  const claims = ticket.getPayload();
  // Confirm this token was actually issued for YOUR Pub/Sub push service account
  if (claims.email !== "your-push-invoker@your-project.iam.gserviceaccount.com" ||
      !claims.email_verified) {
    throw new Error("Unexpected service account");
  }

  const body = JSON.parse(req.body);
  return Buffer.from(body.message.data, "base64").toString("utf-8");
}

Step 2: The Decoupled Architecture

Code example
                               ┌────────────────────────────────┐
                               │   Ingestion Worker (HTTP)       │
                               └───────────────┬──────────────────┘
                                                │
                              ┌──────────────────────────────────┐
                              │  Message Queue (AWS SQS / Kafka) │
                              └────────────────┬─────────────────┘
                                                │
                              ┌──────────────────────────────────┐
                              │ Async Consumer Worker (Resilient)│
                              └────────────────┬─────────────────┘
                                                │
               ┌────────────────────────────────┼────────────────────────────────┐
               ▼                                ▼                                ▼
  ┌─────────────────────────┐     ┌─────────────────────────┐     ┌─────────────────────────┐
  │  1. Check Idempotency    │     │  2. Fetch Store State    │     │  3. Timestamp Compare   │
  │     (Redis / DB Key)     │     │   (Store API Lookup)     │     │  (Prevent Stale Writes) │
  └─────────────────────────┘     └─────────────────────────┘     └─────────────────────────┘
  • Fast ack: the ingestion worker verifies the signature/token, writes the raw message to SQS/Kafka, and responds within milliseconds.
  • Durable processing: background workers pull from the queue.
  • Dead Letter Queue (DLQ): messages failing after several retries move to a DLQ for manual review.

Step 3: Enforcing Idempotency and Order Independence

Idempotency key design:

  • Apple: apple_evt_${notificationUUID} (retries reuse the same UUID, so this alone deduplicates true retries — but remember CONSUMPTION_REQUEST resends use a new UUID each time by design, so don't treat repeated CONSUMPTION_REQUEST events as duplicates)
  • Google: google_evt_${messageId} — Google's own docs recommend checking messageId uniqueness yourself to avoid redundant API calls and quota burn

Store the key in Redis or Postgres with a short TTL (e.g., 7 days); skip processing if the key already exists.

State reconciliation logic (unchanged in principle from before — still a sound pattern, now aware that is_active_entitlement should also factor in partial-refund percentages from Apple's revocationPercentage rather than treating every refund as a full revocation):

Code example
import datetime

def process_subscription_update(
    db_session,
    user_id: str,
    original_tx_id: str,
    new_expires_date: datetime.datetime,
    event_timestamp: datetime.datetime,
    is_active_entitlement: bool,
) -> bool:
    """Safely updates entitlement records, protecting against out-of-order webhooks."""
    current_sub = db_session.query(Subscription).filter_by(
        original_transaction_id=original_tx_id
    ).first()

    if current_sub:
        if event_timestamp <= current_sub.last_processed_event_time:
            logger.info(f"Skipping stale event for {original_tx_id}")
            return False

        if not is_active_entitlement and current_sub.expires_date > new_expires_date:
            logger.info(f"Ignoring premature revocation attempt for {original_tx_id}")
            return False

        current_sub.is_active = is_active_entitlement
        current_sub.expires_date = max(current_sub.expires_date, new_expires_date)
        current_sub.last_processed_event_time = event_timestamp
    else:
        db_session.add(Subscription(
            user_id=user_id,
            original_transaction_id=original_tx_id,
            is_active=is_active_entitlement,
            expires_date=new_expires_date,
            last_processed_event_time=event_timestamp,
        ))

    db_session.commit()
    return True

6. Defense-in-Depth: Daily Replay & State Reconciliation

Webhooks are best-effort. Complement them with a scheduled reconciliation job.

Code example
┌────────────────────────────────────────────────────────────────────────┐
│               Daily Cron Job (Every 24 Hours / Off-Peak)                │
└───────────────────────────────────┬────────────────────────────────────┘
     ┌──────────────────────────────┴──────────────────────────────┐
     ▼                                                              ▼
┌─────────────────────────┐                       ┌──────────────────────┐
│ Apple App Store API     │                       │ Google Play API      │
│ (Transaction History &  │                       │ (subscriptionsv2.get,│
│  Subscription Statuses) │                       │  productsv2, voided) │
└────────────┬────────────┘                       └──────────┬───────────┘
             └──────────────────────┬────────────────────────┘
                                     ▼
┌────────────────────────────────────────────────────────────────────────┐
│           Reconcile DB Entitlements with Store Server State            │
└────────────────────────────────────────────────────────────────────────┘

Apple App Store Server API fallback:

  • Get Transaction History: GET https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}
  • Get All Subscription Statuses: GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}

Google Play Developer API fallback (use the current, non-deprecated endpoints):

  • Subscriptions: GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token} — do not call the legacy purchases.subscriptions.get v1 endpoint in new code; it's deprecated and scheduled for shutdown.
  • One-time products: purchases.productsv2.getproductpurchasev2 for consumables and non-consumables.
  • Voided purchases: the Voided Purchases API as a pull-based backstop for voidedPurchaseNotification.

7. Operational Testing and Monitoring

Testing Environments

Apple App Store Sandbox & Test Notifications Use POST /inApps/v1/notifications/test to trigger a notificationType: "TEST" notification to your sandbox URL, and Get Test Notification Status to inspect delivery attempts and response codes. Remember: sandbox never retries a failed attempt, so debugging with a breakpoint in the handler can make it look like notifications "never arrive" when they were actually sent once and dropped after your timeout.

Google Play Pub/Sub Test Messages In Google Play Console, under Monetization setup, use "Send test message" to publish a sample payload to your Cloud Pub/Sub topic, or pull manually:

Code example
gcloud pubsub subscriptions pull projects/my-backend-project/subscriptions/rtdn-dev-sub --auto-ack

Critical Metrics & Alerting Thresholds

  • Webhook ingestion p99 latency — alert above 200ms.
  • Ingestion failure rate — alert if 4xx/5xx responses exceed 0.1% of volume.
  • Queue backlog depth — alert if messages sit unconsumed for more than 5 minutes.
  • DLQ volume — alert on any message reaching the DLQ.
  • Entitlement mismatch rate from your daily reconciliation job — alert above 0.01%.
  • Chargeback review SLA — new metric: track time-to-response on pendingRefundReviewNotification against Google's 24-hour window; this one has a hard external deadline, not just an internal quality bar.

8. Final Checklist for Mobile Backend Engineers

  • Decoupled architecture: ingestion endpoints enqueue and return HTTP 200 immediately.
  • Real cryptographic verification: Apple payloads verified with the official App Store Server Library (full chain to Apple Root CA, not just a signature check); Google Pub/Sub push endpoint has authentication enabled and its OIDC token verified on every request.
  • Signal processing: Android RTDN payloads trigger an immediate subscriptionsv2.get / productsv2 lookup — never the deprecated v1 subscriptions endpoint.
  • Idempotent workers: dedup by notificationUUID (Apple) / messageId (Google), with awareness that CONSUMPTION_REQUEST resends are intentional, not duplicates.
  • Order independence: filter stale events using store timestamps (signedDate / eventTimeMillis), not arrival time.
  • Partial-refund awareness: treat Apple's revocationPercentage and Google's refundType as first-class fields, not binary "refunded / not refunded."
  • Chargeback SLA: pendingRefundReviewNotification gets a 24-hour response path to the ReviewRefund API, with its own alert.
  • Regulatory events: RESCIND_CONSENT handling doesn't assume an originalTransactionId will be present.
  • Daily state reconciliation: scheduled jobs poll current (non-deprecated) store APIs to repair missed events.
  • Client-side compliance: confirm your Android client is on Play Billing Library 8+ (required for app updates since August 31, 2026).
  • Observability: alerts configured for DLQ depth, ingestion latency, validation failures, and chargeback SLA breaches.

By isolating your ingestion layer, verifying signatures with the platforms' own libraries rather than partial DIY implementations, and reconciling against current (not deprecated) store APIs, you can build an IAP pipeline that protects subscription revenue and stays correct as both platforms keep evolving their notification systems underneath you.


Sources & Further Reading