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

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
┌─────────────────────────────────────────────────────────────────────────┐
│ 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:
{
"signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlF...[truncated JWS]..."
}
Decoding this outer JWS reveals the root notification envelope:
{
"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 nooriginalTransactionId, 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 anexternalPurchaseTokenobject instead of the usualdataobject, and reporting it back requires calling the External Purchase Server API's report endpoint.ONE_TIME_CHARGE,METADATA_UPDATE, andMIGRATIONwere added alongside a broader 2025–2026 changelog push that also introducedrevocationType/revocationPercentagefields on transactions (for partial refunds) and, as of April 2026,TransactionCommitmentInfo/billingPlanTypefields 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.
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:
{
"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:
{
"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):
{
"version": "1.0",
"notificationType": 1,
"purchaseToken": "purchase-token-string",
"sku": "remove_ads"
}
1=ONE_TIME_PRODUCT_PURCHASED2=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:
{
"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:
{
"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:
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:
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
┌────────────────────────────────┐
│ 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 rememberCONSUMPTION_REQUESTresends use a new UUID each time by design, so don't treat repeatedCONSUMPTION_REQUESTevents as duplicates) - Google:
google_evt_${messageId}— Google's own docs recommend checkingmessageIduniqueness 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):
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.
┌────────────────────────────────────────────────────────────────────────┐
│ 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 legacypurchases.subscriptions.getv1 endpoint in new code; it's deprecated and scheduled for shutdown. - One-time products:
purchases.productsv2.getproductpurchasev2for 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:
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
pendingRefundReviewNotificationagainst 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/productsv2lookup — never the deprecated v1 subscriptions endpoint. - Idempotent workers: dedup by
notificationUUID(Apple) /messageId(Google), with awareness thatCONSUMPTION_REQUESTresends are intentional, not duplicates. - Order independence: filter stale events using store timestamps (
signedDate/eventTimeMillis), not arrival time. - Partial-refund awareness: treat Apple's
revocationPercentageand Google'srefundTypeas first-class fields, not binary "refunded / not refunded." - Chargeback SLA:
pendingRefundReviewNotificationgets a 24-hour response path to theReviewRefundAPI, with its own alert. - Regulatory events:
RESCIND_CONSENThandling doesn't assume anoriginalTransactionIdwill 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