Reliable Apple Subscription Notifications with Durable Objects
Apple sends App Store Server Notifications V2 for every subscription event: purchase, renewal, expiry, refund, revocation. These notifications must be processed exactly once and in order. TermOnMac uses a per-user Durable Object to handle this with three layers of protection.
The Three Layers
// relay_server/src/subscription-do.ts
private async handleAppleNotification(request: Request): Promise<Response> {
const { signedPayload, notificationUUID, signedDate, notificationType } = await request.json();
// 1. Dedup: skip if this notificationUUID was already processed
// 2. Stale downgrade protection: reject out-of-order EXPIRED/GRACE_PERIOD_EXPIRED
// 3. Process the notification
}
Each layer guards against a specific failure mode.
Layer 1: UUID Dedup
Apple may deliver the same notification multiple times. If the relay server returns 5xx (or even if it returns 200 but Apple’s delivery system retries due to a network blip), the same notificationUUID will arrive again.
if (notificationUUID) {
const existing = await this.state.storage.get(`uuid:${notificationUUID}`);
if (existing) {
console.log(`[subscription-do] duplicate notification skipped: ${notificationUUID}`);
return Response.json({ handled: true, detail: "duplicate" });
}
}
The UUID is stored with the timestamp of when it was processed. The dedup TTL is 7 days (UUID_TTL_MS = 7 * 24 * 60 * 60 * 1000) — longer than any reasonable retry window.
After successful processing:
if (notificationUUID) {
await this.state.storage.put(`uuid:${notificationUUID}`, Date.now());
}
Layer 2: Stale Downgrade Protection
Out-of-order delivery is a separate problem from duplication. Apple may deliver notifications in non-chronological order, especially after a delivery system outage. An old EXPIRED notification arriving after a recent DID_RENEW would incorrectly downgrade a currently-active subscriber.
The DO tracks the signedDate (the timestamp Apple put on the notification when signing it) and rejects stale downgrades:
// Notification types where stale (out-of-order) delivery should be rejected.
// REFUND and REVOKE are NOT included — Apple-initiated revocations must always be honored.
private static readonly STALE_PROTECTED: ReadonlySet<string> = new Set([
"EXPIRED",
"GRACE_PERIOD_EXPIRED",
]);
if (notificationType && signedDate && SubscriptionDO.STALE_PROTECTED.has(notificationType)) {
const lastSignedDate = await this.state.storage.get<number>("lastSignedDate") ?? 0;
if (signedDate < lastSignedDate) {
console.log(`[subscription-do] stale downgrade rejected: type=${notificationType} signedDate=${...} < last=${...}`);
return Response.json({ handled: true, detail: "stale_downgrade_rejected" });
}
}
The set is intentionally narrow. Only EXPIRED and GRACE_PERIOD_EXPIRED are protected from staleness — these are the notifications that downgrade a user. REFUND and REVOKE are explicitly excluded because Apple-initiated revocations must always be honored regardless of order.
After processing, lastSignedDate is updated:
if (signedDate) {
const lastSignedDate = await this.state.storage.get<number>("lastSignedDate") ?? 0;
if (signedDate > lastSignedDate) {
await this.state.storage.put("lastSignedDate", signedDate);
}
}
The check signedDate > lastSignedDate ensures that even if a newer notification was processed first, processing an older one (that wasn’t a stale-protected type) doesn’t move the timestamp backward.
Layer 3: Per-User Serialization
A Durable Object instance serializes all requests for a given user. Two notifications arriving simultaneously cannot race against each other — one is processed completely before the other begins.
This is enforced by the DO’s per-instance single-threaded execution model. There’s no need for explicit locking; the runtime guarantees serial execution.
Verify-Path Stale Protection
The same protection applies to /verify (called when the iOS app reports a transaction):
private async handleVerify(request: Request): Promise<Response> {
const { userId, jws } = await request.json();
const result = await verifyAndUpdateSubscription(this.env.AUTH_KV, userId, jws);
// Sync to DO storage — only if this subscription is newer than the current one.
// Upgrades may re-fire old transactions via Transaction.updates; we ignore stale ones.
if (result.success && result.subscriptionData) {
const currentSub = await this.state.storage.get("subscription") as Record<string, unknown> | null;
const currentPurchaseDate = (currentSub?.purchase_date as number) ?? 0;
const newPurchaseDate = ((result.subscriptionData as Record<string, unknown>).purchase_date as number) ?? 0;
const newExpiresDate = (result.subscriptionData as Record<string, unknown>).expires_date as number | undefined;
if (newPurchaseDate >= currentPurchaseDate && (!newExpiresDate || newExpiresDate > Date.now())) {
await this.state.storage.put("tier", result.tier);
await this.state.storage.put("subscription", result.subscriptionData);
}
}
return Response.json(result);
}
The comment explains why: Transaction.updates in StoreKit 2 may re-fire old transactions when a user upgrades their subscription. These stale transactions should not overwrite a newer subscription. The check newPurchaseDate >= currentPurchaseDate ensures we only update on equal-or-newer transactions, and newExpiresDate > Date.now() ignores already-expired transactions.
Cleanup Alarm
UUID dedup keys are cleaned up by a 24-hour alarm:
const UUID_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const ALARM_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
async alarm(): Promise<void> {
const cutoff = Date.now() - UUID_TTL_MS;
const entries = await this.state.storage.list({ prefix: "uuid:" });
const toDelete: string[] = [];
for (const [key, ts] of entries) {
if (typeof ts === "number" && ts < cutoff) {
toDelete.push(key);
}
}
if (toDelete.length > 0) {
await this.state.storage.delete(toDelete);
}
this.state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
}
The alarm is registered in the constructor:
constructor(state: DurableObjectState, env: Env) {
state.storage.getAlarm().then(alarm => {
if (!alarm) state.storage.setAlarm(Date.now() + ALARM_INTERVAL_MS);
});
}
getAlarm checks whether an alarm is already scheduled before setting a new one — avoiding alarm storms if multiple constructor invocations race.