// guides
Forward App Store Server Notifications to multiple endpoints
Apple gives every app exactly one production notification URL. Your backend wants the events. So does RevenueCat. So does your analytics pipeline. Here's how to fan out Server Notifications V2 correctly, meaning without breaking anyone's signature verification.
last updated 2026-07-11 · by revenuehog
The one-URL constraint
In App Store Connect, each app has one field for a Production Server URL and one for a Sandbox Server URL (Apps → your app → App Information → App Store Server Notifications). Not a list: one URL per environment, per app. Whoever owns that URL receives every subscription lifecycle event Apple sends: purchases, renewals, cancellations, billing failures, refunds.
The moment two systems need those events (your own backend plus RevenueCat, an analytics warehouse plus a Slack bot), someone has to receive the notification and pass it on. That someone is a relay, and there is exactly one correct way to build it.
What breaks the naive approaches
Every notification is a JSON body with a single field: signedPayload, a JWS signed by Apple with a certificate chain ending at Apple's root CA. Downstream consumers verify that chain; it's their proof the event is real. That's what the tempting shortcuts destroy:
- ▸Decode and re-POST the fields.You've stripped the signature entirely. Consumers that verify (all the serious ones, RevenueCat included) reject the request; the ones that don't are accepting unauthenticated revenue data, which is worse.
- ▸Re-sign with your own key.Downstream verifiers pin Apple's root certificate, not yours. A payload signed by anyone-who-isn't-Apple fails verification by design. That pinning is the whole security model.
- ▸Wrap it in your own envelope (
{ "source": "relay", "payload": … }). Consumers expect Apple's exact body shape at their Apple-notification endpoints; a wrapped body isn't parseable as one.
The correct move is almost anticlimactic: forward the verbatim body. The signature covers the signedPayload string, so preserve it exactly as received. The simplest way to guarantee that is to relay the raw request body byte-for-byte, unparsed. Every consumer then verifies the JWS exactly as if Apple had called it directly, because cryptographically, it did.
A useful corollary: you don't need to add your own HMAC or shared secretto forwarded notifications. Apple's signature already authenticates the payload end-to-end. A receiver that verifies the JWS gets integrity and authenticity for free, no matter how many relays the body passed through.
Building the relay
Two components: the endpoint Apple calls, and the fan-out worker. The endpoint's contract is verify → persist → ack fast → fan out asynchronously:
// relay/route.ts: the endpoint Apple actually calls. The contract:
// verify → persist → ack fast → fan out asynchronously.
import { verifySignedPayload } from "./verify"; // see the V2 guide
import { enqueueFanout } from "./queue";
export async function POST(req: Request) {
// 1. Keep the RAW body. The bytes you received are the bytes you
// forward. Never parse-and-re-serialize what you send downstream.
const rawBody = await req.text();
let payload;
try {
payload = await verifySignedPayload(rawBody); // x5c → Apple root, ES256
} catch {
return new Response("Unauthorized", { status: 401 });
}
// 2. Persist idempotently. Apple retries, networks duplicate.
const isNew = await recordOnce(payload.notificationUUID, {
rawBody,
environment: payload.data.environment, // routes the fan-out
});
// 3. Enqueue the fan-out and ack Apple IMMEDIATELY. If a downstream
// is slow or down, that's your queue's problem, not a failed
// delivery in Apple's eyes.
if (isNew) await enqueueFanout(payload.notificationUUID);
return new Response("OK", { status: 200 });
}Ack semantics matter more than they look. Apple treats slow and non-2xx responses as failed deliveries and re-sends on its retry schedule (1, 12, 24, 48, 72 hours). If you forward synchronouslyand one downstream is down, your endpoint times out, Apple marks the delivery failed, and every other consumer's events now arrive hours late. One consumer's outage became everyone's. Decouple: ack Apple once the event is verified and safely stored, then deliver from a queue.
// worker.ts: deliver to each endpoint independently, with retries.
const ENDPOINTS: Record<string, string[]> = {
Production: [
"https://api.your-backend.example/apple/notifications",
"https://api.revenuecat.com/…", // RevenueCat's Apple notification URL
],
Sandbox: ["https://staging.your-backend.example/apple/notifications"],
};
export async function fanout(notificationUUID: string) {
const ev = await load(notificationUUID);
// Per-endpoint delivery state: one slow consumer must not block or
// re-send to the others.
for (const url of ENDPOINTS[ev.environment] ?? []) {
await deliverWithRetry(url, ev.rawBody); // your queue's retry policy
}
}
async function deliverWithRetry(url: string, rawBody: string) {
// POST the verbatim body. 2xx = delivered; anything else retries with
// exponential backoff (we use 3 attempts, 15s timeout per attempt,
// then park the job in a dead-letter set for inspection).
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: rawBody,
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // → queue retries
}Design decisions worth stealing:
- ▸Per-endpoint independence. Each destination gets its own delivery state and retries. A failure to endpoint B never re-sends to endpoint A.
- ▸Environment routing. Production events go only to production endpoints, sandbox to sandbox. Mixing them corrupts downstream metrics (sandbox subscriptions renew on an accelerated clock).
- ▸Bounded retries with a dead letter. Retry with exponential backoff a few times (we use 3 attempts with a 15-second timeout each), then park the event where a human can inspect and replay it. Infinite retries against a misconfigured URL are just a slow outage generator.
- ▸Ordering honesty.Retries reorder events. Guaranteed eventually-in-order delivery is a lie someone will build on. Consumers must order by the payload's own
signedDateand dedupe onnotificationUUID, which they already needed to do for Apple's own retries. - ▸SSRF rules for configurable URLs. If endpoint URLs are user input (a multi-tenant relay, a dashboard field), validate them: HTTPS only, no credentials in the URL, and reject private, loopback and link-local hosts. Otherwise your relay is a proxy into your own network.
What downstream consumers see
| Concern | With a verbatim relay |
|---|---|
| Signature verification | Unchanged: the x5c chain and ES256 signature verify against Apple's root exactly as with direct delivery. |
| Body shape | Identical: { "signedPayload": "…" }, content-type application/json. |
| Duplicates | Possible (relay retries + Apple retries). Dedupe on notificationUUID, as always. |
| Ordering | Not guaranteed. Order by signedDate in the payload, not arrival time. |
| Source IP / TLS client | The relay's, not Apple's. Consumers authenticating by IP allowlist (rare, and fragile even with Apple) need updating. |
The managed version
This relay is a real service to run: queue infrastructure, retry tuning, delivery logs someone can actually read, and the URL validation above. RevenueHog runs exactly this as a built-in feature: point Apple's notification URL at RevenueHog, add your existing endpoints (your backend, RevenueCat, anything) as per-app forwarding URLs, and every verified event is re-POSTed verbatim with the retry/dead-letter behavior described here, plus a per-event delivery log. The mechanics are documented in the forwarding reference.
sources
Apple: App Store Server Notifications V2 (one production + one sandbox URL per app; retry schedule) — developer.apple.com/documentation/appstoreservernotifications.
Apple: Enabling App Store Server Notifications — developer.apple.com/documentation/appstoreservernotifications/enabling-app-store-server-notifications.
Checked 2026-07-11.
// related