DEV Community

InstaWebhook
InstaWebhook

Posted on

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 webhooks
Apple App Store server-to-server notifications
Apple App Store webhooks
Apple JWS notification decoding
App Store billing notifications
App Store notification types
App Store Server Notifications V2
automated IAP entitlement updates
dead letter queue for webhooks
durable webhook pipelines
Google Cloud PubSub IAP webhooks
Google Play Billing Library S2S
Google Play billing webhooks
Google Play billing webhooks architecture
Google Play developer notifications
Google Play Pub Sub notifications
Google Play RTDN setup
Google Play subscription notifications
Google RTDN
handling Apple App Store notifications
handling App Store refunds
handling Google Play grace periods
handling IAP cancellations
handling S2S notifications
IAP backend architecture
IAP backend queue worker
IAP refund webhooks
idempotent S2S notification processing
in-app purchase webhooks
in-app subscription lifecycle events
iOS in-app purchase S2S
mobile app monetization backend
mobile backend development
mobile IAP event processing
mobile IAP webhooks
mobile in app purchase server notification
mobile subscription backend infrastructure
mobile subscription management
mobile subscription webhooks
preventing subscription revenue loss
Real-Time Developer Notifications
reliable webhook ingestion
S2S notification signature verification
S2S notification verification
S2S webhook failure handling
S2S webhook processing
server-to-server billing sync
StoreKit 2 server notifications
StoreKit server-to-server notifications
subscription entitlement sync
subscription revenue loss prevention
subscription status updates
webhook queue architecture
webhook replay pipeline
webhook 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
    Copy code
    ┌─────────────────────────────────────────────────────────────────────────┐
    │ 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 │
    └─────────────────────────────────────────────────────────────────────────┘
    Dimension Apple App Store Server Notifications V2 Google Play Real-Time Developer Notifications (RTDN)
    Transport Layer Direct webhook HTTP POST from Apple Google Cloud Pub/Sub (push HTTP or pull client)
    Payload Format Signed compact JSON Web Signature (JWS) Base64-encoded JSON wrapper
    Security Mechanism X.509 certificate chain (x5c) chained to an Apple Root CA Pub/Sub OIDC bearer token (Google-signed JWT), or GCP IAM for pull subscriptions
    Payload Nature Self-contained event: includes full transaction & renewal state Signal event: notification only; payload requires a developer API lookup
    Primary Keys originalTransactionId, transactionId purchaseToken, subscriptionId / sku
    Retry Strategy 5 fixed retries (not exponential) at 1, 12, 24, 48, and 72 hours after the previous attempt — production only; sandbox sends once with no retry Cloud 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
Copy code
{
"signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlF...[truncated JWS]..."
}
Decoding this outer JWS reveals the root notification envelope:

Code example
Copy code
{
"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:

Category notificationType values
Subscription lifecycle SUBSCRIBED, DID_RENEW, EXPIRED, DID_FAIL_TO_RENEW, GRACE_PERIOD_EXPIRED, DID_CHANGE_RENEWAL_STATUS, DID_CHANGE_RENEWAL_PREF
Refunds & disputes REFUND, REFUND_DECLINED, REFUND_REVERSED, CONSUMPTION_REQUEST
Pricing PRICE_INCREASE, PRICE_CHANGE
Offers & renewal extensions OFFER_REDEEMED, RENEWAL_EXTENDED, RENEWAL_EXTENSION
Revocation REVOKE
One-time purchases ONE_TIME_CHARGE
External / alternative payment EXTERNAL_PURCHASE_TOKEN
Account & consent state RESCIND_CONSENT, METADATA_UPDATE, MIGRATION
Testing TEST
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 200–206 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.

  1. 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
Copy code
{
"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
Copy code
{
"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:

Type Meaning

1 SUBSCRIPTION_RECOVERED Recovered from account hold or resumed from pause
2 SUBSCRIPTION_RENEWED Active subscription renewed
3 SUBSCRIPTION_CANCELED Voluntarily or involuntarily canceled
4 SUBSCRIPTION_PURCHASED New subscription purchased
5 SUBSCRIPTION_ON_HOLD Entered account hold
6 SUBSCRIPTION_IN_GRACE_PERIOD Entered grace period
7 SUBSCRIPTION_RESTARTED User restored a canceled-but-not-yet-expired subscription from Play > Account > Subscriptions
8 SUBSCRIPTION_PRICE_CHANGE_CONFIRMED Deprecated — use type 19 instead
9 SUBSCRIPTION_DEFERRED Recurrence time extended
10 SUBSCRIPTION_PAUSED Subscription paused
11 SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED Pause schedule changed
12 SUBSCRIPTION_REVOKED Revoked before expiration (e.g., refund)
13 SUBSCRIPTION_EXPIRED Reached end of lifecycle
17 SUBSCRIPTION_ITEMS_CHANGED An item in a subscription bundle changed
18 SUBSCRIPTION_CANCELLATION_SCHEDULED Installment-subscription cancellation scheduled for end of commitment period
19 SUBSCRIPTION_PRICE_CHANGE_UPDATED A subscription item's price-change details were updated (replacement for type 8)
20 SUBSCRIPTION_PENDING_PURCHASE_CANCELED A pending transaction was canceled
22 SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED Consent 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
Copy code
{
"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
Copy code
{
"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
Copy code
{
"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.

  1. The Systemic Failure Modes of Mobile IAP Webhooks
  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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
Copy code
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 on every request:

Code example
Copy code
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
Copy code
┌────────────────────────────────┐
│ 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
Copy code
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
Enter fullscreen mode Exit fullscreen mode
  1. Defense-in-Depth: Daily Replay & State Reconciliation Webhooks are best-effort. Complement them with a scheduled reconciliation job.

Code example
Copy code
┌────────────────────────────────────────────────────────────────────────┐
│ 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.

  1. 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
Copy code
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.

  1. 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
Apple: App Store Server Notifications
Apple: App Store Server Notifications changelog
Apple: Responding to App Store Server Notifications
Apple: externalPurchaseToken
Apple App Store Server Library (Node.js, GitHub)
Apple PKI — root certificates
Google: Real-time developer notifications reference
Google: Subscription lifecycle
Google: One-time purchase lifecycle
Google: Play Developer API deprecations
Google Cloud: Authenticate Pub/Sub push subscriptions

Top comments (0)