RevenueHog

// Guides

App Store Server Notifications V2: the complete setup guide

Configure the URLs in App Store Connect, meet Apple's endpoint requirements, verify the signedPayload JWS properly (x5c chain to Apple's root, ES256), understand what all 23 notificationType values actually mean, get the In-App Purchase key and Issuer ID right, and test the whole thing, with working TypeScript for each step.

Last updated 2026-08-11 · by RevenueHog

What Server Notifications V2 is

App Store Server Notifications are HTTPS POSTs Apple sends your server the moment something happens to an in-app purchase: a subscription starts, renews, fails to bill, gets refunded, expires. They are the only way to know about these events in real time. App Store Connect's daily reports trail reality by about a day, and the app itself isn't running when a renewal happens at 3am.

Version 2 is the current protocol: one cryptographically signed JWS payload per event, a clean notificationType + subtype taxonomy, and coverage of the full subscription lifecycle. Version 1 (and the verifyReceipt endpoint it grew up with) is deprecated. Build anything new on V2.

Configure the URLs in App Store Connect

  • In App Store Connect, open Apps → your app → General → App Information.
  • Under App Store Server Notifications, set the Production Server URL to your HTTPS endpoint and select Version 2.
  • Set the Sandbox Server URL too (same endpoint or a different one), also Version 2.
  • Save. Changes take effect quickly; no app release needed.

The constraint that shapes every architecture decision here: Apple allows exactly one production URL per app (plus one sandbox URL). You cannot point one app at your backend and RevenueCat and an analytics tool. Whoever receives the notification has to forward it to everyone else. See the FAQ for how to do that without breaking signature verification.

Your endpoint must be publicly reachable over HTTPS with a certificate browsers would trust: no self-signed certs, no internal hostnames. Apple documents four more requirements that are easy to miss and produce silent delivery failures:

  • TLS 1.2 or later. Older protocol versions are refused.
  • Port 443, or any port at or above 1024. A URL like https://example.com:8080/notifications is fine; :8443 is fine; :80 is not.
  • Allow Apple's 17.0.0.0/8 subnet if your server sits behind an IP allow list. The same subnet covers sandbox and production.
  • Answer with 200 to 206. Apple treats any status in that range as success, and a 4xx or 5xx as "retry this". Every other status code it simply counts as an unsuccessful post.

The payload: one JWS string

Every notification is a small JSON body with a single field:

What Apple sends
POST /your/notifications/endpoint HTTP/1.1
Content-Type: application/json

{
  "signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTUQuLi4iLCJNSUlFVlEuLi4iLCJNSUlDUXouLi4iXX0.eyJub3RpZmljYXRpb25UeXBlIjoiRElEX1JFTkVXIiwibm90aWZ...cw.MEUCIQDw..."
}

signedPayload is a JWS (JSON Web Signature) in compact serialization: three base64url segments, header.payload.signature. The header declares alg: ES256 and carries an x5c certificate chain; the payload decodes to:

Decoded payload (responseBodyV2DecodedPayload)
{
  "notificationType": "DID_RENEW",
  "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20",
  "version": "2.0",
  "signedDate": 1783142400000,
  "data": {
    "appAppleId": 1234567890,
    "bundleId": "com.example.yourapp",
    "environment": "Production",
    "signedTransactionInfo": "<another JWS>",
    "signedRenewalInfo": "<another JWS>"
  }
}

signedTransactionInfo and signedRenewalInfo are themselves JWS strings, nested inside the signed outer payload. The transaction payload carries the useful commerce facts: productId, price (in milliunits of currency: 39990 means 39.99), storefront, expiresDate, offerDiscountType, and so on.

data is not always there

The decoded payload always carries notificationType, an optional subtype, version, signedDate and notificationUUID. After that it carries exactly one of four mutually exclusive objects, and which one depends on the type:

The four payload variants (Apple: responseBodyV2DecodedPayload)
fieldpresent whencontains
dataAlmost every in-app purchase eventEnvironment, app metadata, and the nested signedTransactionInfo / signedRenewalInfo JWS strings.
appDataRESCIND_CONSENTEnvironment, app metadata, and signed app-transaction information.
summaryRENEWAL_EXTENSION with subtype SUMMARYThe results of a bulk renewal-date extension you requested.
externalPurchaseTokenEXTERNAL_PURCHASE_TOKENThe external purchase token, for apps using alternative payment options.

A handler written as payload.data.bundleId works perfectly for months and then throws the first time one of the other three types shows up. Read notificationType first, and treat a missing data as normal.

Verifying the signedPayload

Anyone on the internet can POST JSON to your endpoint. Base64 decoding the payload and trusting it is not verification. You must check the signature, and check that the signing chain terminates at Apple's root CA. The practical path is Apple's own server library:

verify.ts · Apple's library (recommended)
// verify.ts: Node 18+, npm i @apple/app-store-server-library
import { readFileSync } from "node:fs";
import {
  Environment,
  SignedDataVerifier,
} from "@apple/app-store-server-library";

// Apple's root CA, downloaded once from
// https://www.apple.com/certificateauthority/ and bundled with your app.
// Every notification's x5c chain must terminate at this certificate.
const appleRoots = [readFileSync("certs/AppleRootCA-G3.cer")];

export function makeVerifier(environment: "Sandbox" | "Production") {
  return new SignedDataVerifier(
    appleRoots,
    true, // enableOnlineChecks: OCSP revocation checks against Apple
    environment === "Production" ? Environment.PRODUCTION : Environment.SANDBOX,
    "com.example.yourapp", // your bundle id (must match data.bundleId)
    // appAppleId is REQUIRED for Production and must be OMITTED for Sandbox.
    // This asymmetry is the single most common verification gotcha.
    environment === "Production" ? 1234567890 : undefined,
  );
}

And the endpoint that uses it:

route.ts · the notification endpoint
// app/api/apple/notifications/route.ts (Next.js shown; the shape is
// identical in Express/Fastify: read the JSON body, verify, ack fast,
// do heavy work asynchronously)
import { makeVerifier } from "./verify";

export async function POST(req: Request) {
  const body = (await req.json().catch(() => null)) as
    | { signedPayload?: string }
    | null;
  if (typeof body?.signedPayload !== "string") {
    return new Response("Bad Request", { status: 400 });
  }

  // Peek data.environment WITHOUT verifying, only to pick which verifier
  // to construct. The verifier re-checks the environment against the
  // signature, so lying here buys an attacker nothing: it fails closed.
  const environment = peekEnvironment(body.signedPayload);

  let payload;
  try {
    payload = await makeVerifier(environment).verifyAndDecodeNotification(
      body.signedPayload,
    );
  } catch {
    // Bad signature, broken chain, or bundle/environment mismatch.
    return new Response("Unauthorized", { status: 401 });
  }

  // Apple can deliver the same notification more than once (retries).
  // Dedupe on notificationUUID before acting on it.
  // await recordOnce(payload.notificationUUID, payload);

  // Respond 200 within seconds; anything else counts as a failed delivery.
  return new Response("OK", { status: 200 });
}

function peekEnvironment(signedPayload: string): "Sandbox" | "Production" {
  const claims = JSON.parse(
    Buffer.from(signedPayload.split(".")[1], "base64url").toString("utf8"),
  ) as { data?: { environment?: string } };
  return claims.data?.environment === "Sandbox" ? "Sandbox" : "Production";
}

Three details in that handler come straight from running this in production:

  • Environment peeking. The verifier must be constructed for the payload's environment, but you only learn the environment from the payload. Decoding it unverified just to pick the verifier is safe: the verifier independently checks that the signed payload's environment matches, so a forged environment fails verification.
  • appAppleId is required for Production and must be omitted for Sandbox. Get this wrong and every notification in one environment fails verification while the other works, which is confusing to debug if you only ever tested in sandbox.
  • Dedupe on notificationUUID. Apple retries deliveries it considers failed, and networks being networks, you will eventually receive duplicates of notifications you already processed.

What verification actually involves

If you want to understand what the library is doing, or you're not on Node, here is the whole procedure, spelled out:

manual-verify.ts · the same checks by hand
// manual-verify.ts: what the library does, spelled out. Prefer the
// library in production (it also performs OCSP revocation checks, which
// this sketch omits). npm i jose
import { readFileSync } from "node:fs";
import { X509Certificate } from "node:crypto";
import { compactVerify, importX509 } from "jose";

const APPLE_ROOT_DER = readFileSync("certs/AppleRootCA-G3.cer");

export async function verifySignedPayload(signedPayload: string) {
  // 1. The JWS protected header carries the signing chain in x5c:
  //    [leaf, intermediate, root], each base64 DER.
  const header = JSON.parse(
    Buffer.from(signedPayload.split(".")[0], "base64url").toString("utf8"),
  ) as { alg?: string; x5c?: string[] };
  if (header.alg !== "ES256" || header.x5c?.length !== 3) {
    throw new Error("unexpected JWS header");
  }
  const [leaf, intermediate, root] = header.x5c.map(
    (b64) => new X509Certificate(Buffer.from(b64, "base64")),
  );

  // 2. Pin the root: byte-identical to the Apple Root CA - G3 you
  //    downloaded from apple.com/certificateauthority. Never trust a
  //    chain just because it is internally consistent.
  if (!root.raw.equals(APPLE_ROOT_DER)) throw new Error("untrusted root");

  // 3. Walk the chain: leaf signed by intermediate, intermediate by root,
  //    and every certificate inside its validity window.
  if (
    !leaf.verify(intermediate.publicKey) ||
    !intermediate.verify(root.publicKey)
  ) {
    throw new Error("broken certificate chain");
  }
  const now = Date.now();
  for (const cert of [leaf, intermediate, root]) {
    if (now < Date.parse(cert.validFrom) || now > Date.parse(cert.validTo)) {
      throw new Error("certificate outside its validity window");
    }
  }

  // 4. Verify the ES256 signature with the leaf's public key. Pin the
  //    algorithm list; never let the header choose it for you.
  const key = await importX509(leaf.toString(), "ES256");
  const { payload } = await compactVerify(signedPayload, key, {
    algorithms: ["ES256"],
  });
  const decoded = JSON.parse(new TextDecoder().decode(payload));

  // 5. Finally, check the payload is for YOUR app and THIS endpoint's
  //    environment.
  if (decoded.data?.bundleId !== "com.example.yourapp") {
    throw new Error("wrong bundleId");
  }
  return decoded;
}

The mistakes that turn "verification" into theater, in the order we see them in the wild: trusting the decoded payload without any signature check; verifying the signature against the leaf certificate without pinning the chain to Apple's root (any attacker can mint a chain that verifies against itself); and letting the JWS header pick the algorithm instead of pinning ES256.

Every notificationType, and what it actually means

The type taxonomy is where most integrations get subtly wrong numbers. Two rules of thumb before the table: a cancellation (AUTO_RENEW_DISABLED) is intent, not churn (the money stops at EXPIRED); and a trial converting to paid arrives as a plain DID_RENEW, so if you want conversion metrics you must remember which subscriptions started as trials.

All 23 App Store Server Notifications V2 types, checked against Apple's documentation on 2026-08-11 (Apple adds types over time; treat unknown ones as no-ops, log them, and return 200 so a new type never breaks ingestion)
notificationTypesubtypeswhat it means in practice
SUBSCRIBEDINITIAL_BUY, RESUBSCRIBEA subscription started: a first-time purchase, or a lapsed subscriber coming back. To tell a free-trial start from a paid start, decode signedTransactionInfo and check offerDiscountType === "FREE_TRIAL".
ONE_TIME_CHARGEA one-time purchase (consumable, non-consumable, or non-renewing subscription).
DID_RENEWBILLING_RECOVERYA renewal billed successfully. The first DID_RENEW of a subscription that started as a free trial is the trial converting to paid (there is no separate "conversion" type). BILLING_RECOVERY means the renewal recovered from billing retry.
DID_CHANGE_RENEWAL_STATUSAUTO_RENEW_ENABLED, AUTO_RENEW_DISABLEDAuto-renew toggled. AUTO_RENEW_DISABLED is cancellation intent: the user keeps access until the period ends, so treat it as "unsubscribed", not lost revenue yet.
DID_CHANGE_RENEWAL_PREFUPGRADE, DOWNGRADEPlan change. Upgrades take effect immediately (new billing period, prorated refund for the unused part of the old one); downgrades apply at the next renewal. No subtype means the customer switched their renewal preference back to the current plan, cancelling a pending downgrade.
DID_FAIL_TO_RENEWGRACE_PERIODA renewal failed to bill and the subscription entered billing retry. With the GRACE_PERIOD subtype, keep serving the subscription through the grace period. With no subtype, there is no grace period and you can stop. Either way this is churn risk, not churn: Apple retries billing for up to 60 days.
EXPIREDVOLUNTARY, BILLING_RETRY, PRICE_INCREASE, PRODUCT_NOT_FOR_SALEThe subscription actually ended: the real churn event. The subtype says why: chose not to renew, billing retry ran out, declined a price increase, or the product was removed from sale.
GRACE_PERIOD_EXPIREDThe billing grace period ended without recovering payment. Entitlement should end now. Also churn.
OFFER_REDEEMEDINITIAL_BUY, RESUBSCRIBE, UPGRADE, DOWNGRADEThe user redeemed a promotional offer or offer code; the subtype says in what context.
REFUNDApple refunded a transaction. Amount and product are in signedTransactionInfo (revocationDate/revocationReason are set).
REFUND_DECLINEDApple declined a refund request. Nothing to do beyond recording it.
REFUND_REVERSEDApple reversed a refund it had previously granted, after a customer dispute. If you revoked content over the refund, reinstate it. For subscriptions the renewal date is unchanged.
REVOKEAn in-app purchase the customer had through Family Sharing is no longer shared: the purchaser turned Family Sharing off, someone left the family group, or the purchaser was refunded. End entitlement for the affected user.
PRICE_INCREASEPENDING, ACCEPTEDThe customer was told about a subscription price increase. If it needs their consent, PENDING means they haven't responded and ACCEPTED means they agreed. If it doesn't need consent, the subtype is ACCEPTED.
RENEWAL_EXTENDEDApple extended the renewal date of one specific subscription, after you called Extend a Subscription Renewal Date (or the all-subscribers variant) in the App Store Server API.
RENEWAL_EXTENSIONSUMMARY, FAILUREProgress on a bulk extension you requested for all active subscribers: SUMMARY when Apple finished (read the summary object), FAILURE when one subscription's extension failed.
CONSUMPTION_REQUESTA customer asked for a refund on a consumable or a subscription, and Apple invites you to send consumption data (the Send Consumption Information endpoint) to inform the decision.
EXTERNAL_PURCHASE_TOKENCREATED, ACTIVE_TOKEN_REMINDER, UNREPORTEDExternal-purchase token reporting, only for apps using Apple's External Purchase API for alternative payments. The payload carries externalPurchaseToken instead of data.
RESCIND_CONSENTA parent or guardian withdrew consent for a child's use of the app. The payload carries appData instead of data.
METADATA_UPDATE / MIGRATION / PRICE_CHANGEAdvanced Commerce API only: you changed a subscription's metadata, migrated it to the Advanced Commerce API, or changed its price through that API. Apps that don't use Advanced Commerce never see these.
TESTThe notification you requested via the test endpoint (below). Apple sends it in the V2 format even if the URL is configured for V1. Safe to log and ignore.

The key you need: In-App Purchase key, Issuer ID, Key ID

Configuring the notification URLs needs no key at all. But the two things you will want next, firing a test notification and replaying missed notifications, both go through the App Store Server API, and that API wants a JWT. This is where most people lose an afternoon, because App Store Connect issues more than one kind of key and they are not interchangeable.

  • Generate an In-App Purchase key. In App Store Connect: Users and Access → Integrations → then, under Keys in the sidebar, In-App Purchase Generate In-App Purchase Key. This one key works for the App Store Server API, the Advanced Commerce API and the External Purchase Server API, and for nothing else. A team App Store Connect API key (the kind that reads sales reports) will not authenticate these calls.
  • Download the .p8 immediately. The private half is downloadable exactly once, and Apple keeps no copy. Lose it and your only option is revoking the key and generating another.
  • Copy the Key ID shown next to the key. It goes in the JWT header as kid.
  • Copy the Issuer ID from the top of the Keys page. It is a UUID that identifies your team, it is the same for every key your team holds, and it goes in the JWT payload as iss. If you are hunting for "where is my App Store Connect API key issuer ID", that is the answer: near the top of Users and Access → Integrations → Keys, with a Copy link next to it.

The JWT claims themselves differ from the App Store Connect API in two ways worth knowing before you debug a 401:

JWT for the App Store Server API (Apple: Generating JSON Web Tokens for API requests)
claimvalue
algES256, in the header, always.
kidYour Key ID, in the header.
issYour Issuer ID (the UUID).
audappstoreconnect-v1.
bidYour app's bundle ID. The App Store Connect API has no such claim; omit it here and the request fails.
iatIssued-at time, UNIX seconds.
expExpiry, UNIX seconds. Apple rejects tokens that expire more than 60 minutes after iat (the App Store Connect API's limit is 20 minutes, which is why a token generator copied from a sales-report script often "works" but is doing the wrong thing).

Apple's server library builds these tokens for you, which is the reason the sample below never mentions a JWT: you hand AppStoreServerAPIClient the .p8 contents, the Key ID, the Issuer ID and the bundle ID, and it signs each request itself.

Send yourself a test notification

Apple has a dedicated endpoint to fire a synthetic TEST notification at whatever URL is configured. This is the fastest way to prove connectivity, TLS, and verification end to end. Apple sends it in the V2 format even if that URL is configured for V1 notifications.

Request + check a test notification
// npm i @apple/app-store-server-library. Credentials are an
// In-App Purchase key: Users and Access -> Integrations -> Keys ->
// In-App Purchase in App Store Connect. NOT the team API key.
import {
  AppStoreServerAPIClient,
  Environment,
} from "@apple/app-store-server-library";

const client = new AppStoreServerAPIClient(
  privateKeyP8Contents, // the .p8 file's contents
  "ABC123DEFG",         // Key ID
  "12345678-abcd-1234-abcd-1234567890ab", // Issuer ID
  "com.example.yourapp",
  Environment.SANDBOX,  // or PRODUCTION (tests the matching URL)
);

const { testNotificationToken } = await client.requestTestNotification();

// Give Apple a moment, then ask how delivery went:
const status = await client.getTestNotificationStatus(testNotificationToken!);
console.log(status.sendAttempts);
// → [{ attemptDate: 1783142400000, sendAttemptResult: "SUCCESS" }]
// Other results ("TIMED_OUT", "TLS_ISSUE", "CIRCULAR_REDIRECT", …) tell
// you exactly why Apple could not reach your endpoint.

For real lifecycle events, make sandbox purchases from a development build. Sandbox subscriptions renew on an accelerated clock (a "month" is minutes), so you can watch SUBSCRIBED → DID_RENEW → EXPIRED arrive within an hour. Note that sandbox does not retry: Apple attempts each sandbox notification once, so a sandbox endpoint that is briefly down has simply missed the event.

Common failures

  • Slow or non-2xx responses. Apple treats them as failed deliveries and retries later (1, 12, 24, 48, 72 hours). Verify, persist, return 200 in milliseconds; queue everything else.
  • Wrong appAppleId configuration: required in Production, forbidden in Sandbox (see above).
  • Verifying against the wrong environment. A Production-configured verifier rejects Sandbox payloads and vice versa. Branch on data.environment.
  • TLS problems. Self-signed or expired certificates, redirect loops, endpoints behind auth. The test notification's sendAttemptResult names the exact failure.
  • Clock skew. Certificate-validity and signed-date checks assume a roughly correct clock; a drifting server rejects perfectly good notifications.
  • Forgetting idempotency. Retries mean duplicates; dedupe on notificationUUID.
  • Needing the same events in two places. One production URL per app. Plan for forwarding from day one rather than migrating URL ownership later.

Or do it in one click

Everything above is what RevenueHog runs internally: it verifies every notification against Apple's roots, records it, shows it in a live feed with MRR/churn analytics on top, and forwards Apple's verbatim signedPayload to any endpoints you configure, so your own verification keeps working downstream.

FAQ

Can I send App Store Server Notifications to multiple servers?
No. Apple allows exactly one production URL and one sandbox URL per app. To fan out (say, to RevenueCat, your backend, and an analytics service), the endpoint Apple posts to must forward the notification itself. Forward the verbatim signedPayload, not a re-signed or decoded copy, so downstream consumers can still verify Apple's signature. We wrote a full guide to forwarding notifications, and RevenueHog does exactly this as a built-in feature if you'd rather not run a relay.
What happens if my server is down? Are notifications lost?
In production, not immediately. When Apple doesn't get a success response it retries five more times, at 1, 12, 24, 48 and 72 hours after the previous attempt. Beyond that, the Get Notification History endpoint of the App Store Server API replays notifications from the last 180 days in production (30 days in sandbox), so you can reconcile after a longer outage. Sandbox has no retries at all: one attempt per notification.
Which App Store Connect key do I need, and where is the Issuer ID?
For the App Store Server API (test notifications, notification history, subscription statuses) you need an In-App Purchase key, generated under Users and Access → Integrations → Keys → In-App Purchase. A team App Store Connect API key, the kind used for sales reports, does not work for these endpoints. The Issuer ID is the UUID shown near the top of that same Keys page, with a Copy link beside it; it is shared by all of your team's keys and goes in the JWT as iss. The Key ID sits next to the individual key and goes in the header as kid. Full walkthrough in our App Store Connect API key guide.
Why does my App Store Server API request return 401 when the same key works elsewhere?
Three causes, in the order we hit them. First, the wrong key type: the App Store Server API needs an In-App Purchase key, not a team API key. Second, a missing bid claim: the App Store Server API requires your app's bundle ID in the JWT payload, and the App Store Connect API does not, so a token generator borrowed from a sales-report script will be missing it. Third, an expiry over Apple's limit of 60 minutes past iat.
Do sandbox and production need different URLs?
They're separate fields in App Store Connect and can point at the same endpoint or different ones. Either way, branch on data.environment from the payload and verify with a verifier configured for that environment. And remember that appAppleId is required for Production verification but must be omitted for Sandbox.
How do I test without shipping the app?
Two ways: request a TEST notification through the App Store Server API (code above) to prove Apple can reach and you can verify; and make sandbox purchases from a development build, which produce real SUBSCRIBED / DID_RENEW / EXPIRED notifications in the Sandbox environment (sandbox subscriptions renew on an accelerated clock, so you can watch a full lifecycle in minutes).
Do notifications replace receipt validation?
They replace the old habit of polling verifyReceipt, which Apple has deprecated along with V1 notifications. The modern stack is: StoreKit 2 signed transactions on device, App Store Server API for "what is this subscription's state right now", and Server Notifications V2 for "tell me the moment it changes". Notifications are the push half; the Server API is the pull half. You generally want both.

sources

Apple: App Store Server Notifications, developer.apple.com/documentation/appstoreservernotifications (notificationType and subtype values, responseBodyV2DecodedPayload, Enabling App Store Server Notifications for the TLS, port and 17.0.0.0/8 requirements, Responding to App Store Server Notifications for the status codes and retry schedule).

Apple: App Store Server API, developer.apple.com/documentation/appstoreserverapi (Creating API keys to authorize API requests for the In-App Purchase key, Generating JSON Web Tokens for API requests for the iss/bid/exp claims, Get Notification History for the 180-day and 30-day windows).

Apple: certificate authority downloads, apple.com/certificateauthority (Apple Root CA - G3).

apple/app-store-server-library-node: github.com/apple/app-store-server-library-node.

Checked 2026-08-11.

// Related