System Integration

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.