starti.app
How-to Guides

Handle Apple Server Notifications

Receive and process App Store Server Notifications V2 to track subscription lifecycle events on your backend

Handle Apple Server Notifications

When a user subscribes, renews, cancels, or gets refunded, Apple sends a signed HTTP POST to your server in real time. These are called App Store Server Notifications V2.

Without server notifications, your backend only knows about subscription changes when the app explicitly checks — which doesn't happen if the user isn't actively using the app. Server notifications let you react immediately: revoke access on refund, send win-back emails on cancellation, or extend grace periods on billing failures.

Notification types

Each notification has a notificationType and an optional subtype. Here are the most common ones and what to do with them:

TypeSubtypeWhat happenedRecommended action
DID_RENEW— / BILLING_RECOVERYSubscription renewed successfullyExtend the user's access period
DID_CHANGE_RENEWAL_STATUSAUTO_RENEW_DISABLEDUser turned off auto-renewFlag for win-back; access continues until expiry
DID_CHANGE_RENEWAL_STATUSAUTO_RENEW_ENABLEDUser re-enabled auto-renewClear any cancellation flags
DID_FAIL_TO_RENEWGRACE_PERIODPayment failed, but Apple is retryingKeep access active, notify user to update payment method
DID_FAIL_TO_RENEWPayment failed, no grace periodRevoke or restrict access
EXPIREDVOLUNTARY / BILLING_RETRY / PRICE_INCREASESubscription endedRevoke access
REFUNDApple issued a refundRevoke access immediately
SUBSCRIBEDINITIAL_BUYNew subscriptionGrant access (acts as server-side backup for the client flow)
SUBSCRIBEDRESUBSCRIBEUser re-subscribed after expiryRestore access

This table covers the most common types. See Apple's notificationType reference for the full list, including OFFER_REDEEMED, REVOKE, CONSUMPTION_REQUEST, and others.

Verify the signed payload

Apple sends each notification as a JSON body with a single signedPayload field. This is a JWS (JSON Web Signature) signed by Apple — you must verify it before trusting the contents.

Install Apple's official library:

npm install @apple/app-store-server-library

Download Apple's root certificates (you need all three):

Set up the verifier:

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

const appleRootCAs = [
  fs.readFileSync("./certs/AppleRootCA-G2.cer"),
  fs.readFileSync("./certs/AppleRootCA-G3.cer"),
  fs.readFileSync("./certs/AppleComputerRootCertificate.cer"),
];

const verifier = new SignedDataVerifier(
  appleRootCAs,
  true,                      // enable online OCSP checks
  Environment.PRODUCTION,    // or Environment.SANDBOX for testing
  "com.example.myapp",       // your app's bundle ID
  123456789                  // your app's Apple ID (required for production)
);

Verify and decode a notification:

const decoded = await verifier.verifyAndDecodeNotification(signedPayload);

console.log(decoded.notificationType); // e.g. "DID_RENEW"
console.log(decoded.subtype);          // e.g. null or "GRACE_PERIOD"

// Extract the transaction details (also a signed JWS, decoded automatically)
if (decoded.data?.signedTransactionInfo) {
  const transaction = await verifier.verifyAndDecodeTransaction(
    decoded.data.signedTransactionInfo
  );
  console.log(transaction.transactionId);
  console.log(transaction.productId);
  console.log(transaction.expiresDate);
}

Never skip verification. Without it, anyone can POST fake notifications to your endpoint and grant themselves free subscriptions.

Set up in App Store Connect

  1. Open App Store Connect and navigate to your app
  2. Go to App Information (under General)
  3. Scroll to App Store Server Notifications
  4. Enter your production endpoint URL (e.g. https://api.example.com/webhooks/apple)
  5. Select Version 2 — do not use V1, it's a legacy format
  6. Optionally, enter a separate sandbox URL for testing
  7. Click Save

To verify your endpoint is working, use the Request a Test Notification button or call the Request a Test Notification API endpoint.

Node.js reference implementation

A complete Express handler that receives, verifies, and routes Apple server notifications:

import express from "express";
import { SignedDataVerifier, Environment } from "@apple/app-store-server-library";
import fs from "fs";

const app = express();
app.use(express.json());

const verifier = new SignedDataVerifier(
  [
    fs.readFileSync("./certs/AppleRootCA-G2.cer"),
    fs.readFileSync("./certs/AppleRootCA-G3.cer"),
    fs.readFileSync("./certs/AppleComputerRootCertificate.cer"),
  ],
  true,
  Environment.PRODUCTION,
  process.env.APPLE_BUNDLE_ID,
  process.env.APPLE_APP_ID ? Number(process.env.APPLE_APP_ID) : undefined
);

app.post("/webhooks/apple", async (req, res) => {
  const { signedPayload } = req.body;

  if (!signedPayload) {
    return res.sendStatus(400);
  }

  // Always respond quickly — Apple retries on timeout
  res.sendStatus(200);

  try {
    const notification = await verifier.verifyAndDecodeNotification(signedPayload);
    const { notificationType, subtype } = notification;

    // Decode the transaction if present
    let transaction;
    if (notification.data?.signedTransactionInfo) {
      transaction = await verifier.verifyAndDecodeTransaction(
        notification.data.signedTransactionInfo
      );
    }

    switch (notificationType) {
      case "SUBSCRIBED":
        // New subscription or resubscribe — grant access
        await grantAccess(transaction);
        break;

      case "DID_RENEW":
        // Renewal — extend access period
        await extendAccess(transaction);
        break;

      case "DID_CHANGE_RENEWAL_STATUS":
        if (subtype === "AUTO_RENEW_DISABLED") {
          await flagForWinBack(transaction);
        }
        // AUTO_RENEW_ENABLED — clear cancellation flags
        break;

      case "DID_FAIL_TO_RENEW":
        if (subtype === "GRACE_PERIOD") {
          await notifyPaymentIssue(transaction);
        } else {
          await restrictAccess(transaction);
        }
        break;

      case "EXPIRED":
        await revokeAccess(transaction);
        break;

      case "REFUND":
        await revokeAccess(transaction);
        break;

      default:
        console.log(`Unhandled notification: ${notificationType}/${subtype}`);
    }
  } catch (error) {
    console.error("Failed to verify Apple notification:", error);
  }
});

Respond 200 immediately. Apple retries failed deliveries up to 5 times at increasing intervals (1h, 12h, 24h, 48h, 72h). If your endpoint returns a non-2xx status, you'll receive duplicate notifications. Process the notification asynchronously after responding.

Idempotency. Apple may send the same notification more than once. Use notification.notificationUUID to deduplicate — store processed UUIDs and skip any you've already handled.

Sandbox testing. Use Environment.SANDBOX and your sandbox notification URL during development. Sandbox subscriptions renew on an accelerated schedule — durations vary by subscription length and test environment. See Apple's testing documentation for current rates.

See also

On this page