How do App Store Server Notifications V2 behave, and how do you process them idempotently and out of order?

28.4.3 · L5 · Interview

Predict first

What happens to each notification delivered in the order n1, n3, n2, n3, n4?

import Foundation

struct Note { let uuid: String, type: String, subtype: String?, signedAt: Int, user: String, expires: Int? }
struct Sub { var status = "none", expires = 0, lastSigned = 0 }

final class Store {
    var seen = Set<String>(), subs: [String: Sub] = [:], log: [String] = []
    func handle(_ n: Note) {
        guard seen.insert(n.uuid).inserted else { log.append("\(n.uuid) duplicate: ignored"); return }
        var s = subs[n.user] ?? Sub()
        guard n.signedAt >= s.lastSigned else { log.append("\(n.uuid) stale (\(n.signedAt) < \(s.lastSigned)): ignored"); return }
        switch (n.type, n.subtype) {
        case ("SUBSCRIBED", _), ("DID_RENEW", _): s.status = "active"; s.expires = n.expires ?? s.expires
        case ("DID_FAIL_TO_RENEW", "GRACE_PERIOD"): s.status = "grace"
        case ("DID_FAIL_TO_RENEW", _): s.status = "retry"
        case ("EXPIRED", _): s.status = "expired"
        case ("REFUND", _), ("REVOKE", _): s.status = "revoked"
        default: break
        }
        s.lastSigned = n.signedAt
        subs[n.user] = s
        log.append("\(n.uuid) \(n.type) applied -> \(s.status)")
    }
}
let notes = [
    Note(uuid: "n1", type: "SUBSCRIBED", subtype: nil, signedAt: 100, user: "u", expires: 1000),
    Note(uuid: "n3", type: "DID_FAIL_TO_RENEW", subtype: "GRACE_PERIOD", signedAt: 300, user: "u", expires: nil),
    Note(uuid: "n2", type: "DID_RENEW", subtype: nil, signedAt: 200, user: "u", expires: 2000),
    Note(uuid: "n3", type: "DID_FAIL_TO_RENEW", subtype: "GRACE_PERIOD", signedAt: 300, user: "u", expires: nil),
    Note(uuid: "n4", type: "REFUND", subtype: nil, signedAt: 400, user: "u", expires: nil),
]
let store = Store()
notes.forEach(store.handle)
store.log.forEach { print($0) }
print("final:", store.subs["u"]!.status, "expires", store.subs["u"]!.expires)
  • n2 is dropped as stale and the second n3 as a duplicate; final status revoked with expiry 1000
  • all five are applied
  • final status is active with expiry 2000

Mental model. Postal mail from one sender: letters can be duplicated or arrive out of order, so you date-stamp them and rebuild the story from the dates, not from the order of delivery.

Server Notifications V2 are HTTPS POSTs to a URL you configure per environment. The body is {"signedPayload": "<JWS>"}; verify it like any JWS, then read notificationType and subtype (for example SUBSCRIBED, DID_RENEW, DID_FAIL_TO_RENEW with GRACE_PERIOD, EXPIRED, REFUND, REVOKE, DID_CHANGE_RENEWAL_STATUS, CONSUMPTION_REQUEST), a unique notificationUUID, signedDate, and nested signed signedTransactionInfo and signedRenewalInfo. Respond 200 only when you have durably accepted the event; otherwise Apple retries on a schedule, and you can ask for missed ones through the notification history endpoint and test the endpoint with the Request a Test Notification API. Delivery is at least once and ordering is not guaranteed, so the handler must be idempotent and order tolerant.

The check is a small reducer: it records each notificationUUID and ignores a repeat, ignores an event whose signedDate is older than the last applied one for that subscriber, and otherwise updates the state (the arrival order here is 1, 3, 2, 3 again, 4). Result: the late renewal (200) is dropped as stale, the duplicate is ignored, the refund wins, and the final state is revoked. That also shows the weakness of pure "apply last" logic: dropping a stale renewal loses its new expiry date, so a real system should store the event, then re-derive state from the transaction data (or fetch the current status from the Server API) instead of trusting event order. Process asynchronously (queue, then respond), keep an event log for audit, and alert on a growing backlog.

The reducer deduplicates by uuid and drops events older than the last applied one; real systems should re-derive instead.

Common traps

  • Using arrival order as event order. Use signed dates and the transaction data inside the event.
  • Doing slow work before replying. A timeout means Apple retries and you handle the same event twice; accept, enqueue, return 200.

They might follow up with

  • How do you recover after downtime? Use the notification history endpoint for the gap and reconcile with subscription status for each user.
  • Do sandbox and production share a URL? Configure them separately and tell them apart by the environment field.

Related questions

  • 28.4.1 How is a StoreKit JWS built, and what must a server check before trusting signedTransactionInfo?
  • 28.2.1 What are the subscription renewal states, how do grace period and billing retry differ, and who should keep…
  • 28.6.2 Design the billing and entitlement platform for an app with iOS, Android and web subscriptions and a backend…
  • 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…

← How do you validate the x5c certificate chain of an App Store JWS…   How do you authenticate to the App Store Server API, and what do you… →