// guides
App Store Server Notifications V2: the complete setup guide
Configure the URLs in App Store Connect, verify the signedPayload JWS properly (x5c chain to Apple's root, ES256), understand what every notificationType actually means, and test the whole thing, with working TypeScript for each step.
last updated 2026-07-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.
The payload: one JWS string
Every notification is a small JSON body with a single field:
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:
{
"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.
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: 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:
// 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 unverifiedjust to pick the verifier is safe: the verifier independently checks that the signed payload's environment matches, so a forged environment fails verification.
- ▸
appAppleIdis 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: 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.
| notificationType | subtypes | what it means in practice |
|---|---|---|
SUBSCRIBED | INITIAL_BUY, RESUBSCRIBE | A 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_CHARGE | — | A one-time purchase (consumable, non-consumable, or non-renewing subscription). |
DID_RENEW | BILLING_RECOVERY | A 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_STATUS | AUTO_RENEW_ENABLED, AUTO_RENEW_DISABLED | Auto-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_PREF | UPGRADE, DOWNGRADE | Plan change. Upgrades take effect immediately; downgrades apply at the next renewal. |
DID_FAIL_TO_RENEW | GRACE_PERIOD | A renewal failed to bill. With the GRACE_PERIOD subtype the user keeps entitlement while Apple retries the charge. This is churn risk, not churn. Don't count it as lost yet. |
EXPIRED | VOLUNTARY, BILLING_RETRY, PRICE_INCREASE, PRODUCT_NOT_FOR_SALE | The 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_EXPIRED | — | The billing grace period ended without recovering payment. Entitlement should end now. Also churn. |
OFFER_REDEEMED | INITIAL_BUY, RESUBSCRIBE, UPGRADE, DOWNGRADE | The user redeemed a promotional offer or offer code; the subtype says in what context. |
REFUND | — | Apple refunded a transaction. Amount and product are in signedTransactionInfo (revocationDate/revocationReason are set). |
REFUND_DECLINED / REFUND_REVERSED | — | A refund request was declined, or a previously granted refund was reversed (e.g. a dispute resolved in your favor). Restore entitlement on REFUND_REVERSED. |
REVOKE | — | Family Sharing access revoked (e.g. the purchaser got a refund or left the family group). End entitlement for the affected user. |
PRICE_INCREASE | PENDING, ACCEPTED | A price increase that requires consent: PENDING when the user hasn't responded, ACCEPTED when they agreed. |
RENEWAL_EXTENDED / RENEWAL_EXTENSION | SUMMARY, FAILURE | Responses to renewal-date extensions you request through the App Store Server API (e.g. compensating users for an outage). |
CONSUMPTION_REQUEST | — | A user asked for a refund on a consumable; Apple invites you to send consumption data (Send Consumption Information endpoint, within 12 hours) to inform the decision. |
EXTERNAL_PURCHASE_TOKEN | UNREPORTED | External-purchase token reporting for apps using alternative payments in regions that allow it. |
TEST | — | The notification you requested via the test endpoint (below). Safe to log and ignore. |
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. It needs an App Store Connect API key:
// npm i @apple/app-store-server-library. Credentials are an
// App Store Connect API key (see our API key guide).
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.
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
appAppleIdconfiguration: 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
sendAttemptResultnames 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?
What happens if my server is down? Are notifications lost?
Do sandbox and production need different URLs?
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?
Do notifications replace receipt validation?
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 V2 — developer.apple.com/documentation/appstoreservernotifications (notification types, retry schedule, signedPayload format).
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-07-11.
// related