ShopKit — Interview Talking Points

The 30-second pitch

“ShopKit is a subscription content app I shipped to the App Store — a notes-meets-articles marketplace where Pro content unlocks via a StoreKit 2 subscription with a 7-day free trial. I built the networking layer from scratch around URLSession with typed errors, a one-shot 401 retry, and TLS pinning. Auth tokens live in the Keychain with .userPresence access control. CI/CD on GitHub Actions ships to TestFlight on every main merge. The interesting design was the subscription state machine — StoreKit 2 surfaces transaction state, but the UI needs a higher-level view that includes billing-retry grace periods and revocation reasons. I built that in a single @Observable that the rest of the app reads through one boolean.”

The 3-minute deep dive (subscription state machine)

“StoreKit 2 gives you Transaction.currentEntitlements and Transaction.updates, both AsyncSequences. But neither directly answers the question the UI cares about: ‘should I show this Pro article right now?’

Because the answer is more nuanced than ‘is the user paying.’ Apple wants you to honor access during a billing-retry grace period — the credit card declined but Apple’s retrying for up to 16 days. Apple also wants you to handle in-grace-period state separately from in-billing-retry. And refunded transactions surface as a separate signal you have to act on within hours, or you’re giving away content the user didn’t pay for.

So I built a SubscriptionStatus enum with seven cases: notSubscribed, inFreeTrial, active, inBillingRetry, inGracePeriod, expired, revoked. A SubscriptionWatcher actor listens to both StoreKit streams and maps every state change to one of those. The UI reads a single allowsProAccess: Bool derived from the enum — which keeps the gate one place, not scattered everywhere.

The actor pattern matters here because Transaction.updates runs forever and you need to handle updates even when the app is backgrounded but reattached. I start the watcher in App.task { } so it lives for the app lifetime, not view lifetime. Tests inject a fake TransactionStream that yields canned transactions and assert state transitions.

The hardest case was refund. When Apple processes a refund, you get a Transaction.updates notification, but the expirationDate is already in the past, so you have to use revocationDate and revocationReason. I missed this for a week and was technically letting refunded users keep Pro access until their original expiration. The audit-log query showed maybe 12 incidents. Lesson: always test revocation explicitly with a sandbox refund.“

12 interview questions

1. “Walk me through your networking layer.”

APIClient is a thin async/await wrapper around URLSession. Every endpoint is a struct conforming to APIRequest with its own Response: Decodable. The client serializes the request, attaches the auth header from an injected AuthProvider, runs URLSession.data(for:), maps the HTTP status to a typed APIError (unauthorized, notFound, server, decoding, transport), and decodes on success. 401 triggers one token refresh and a single retry — never a loop. The whole thing is about 80 lines. Testability comes from injecting a stub URLSession via URLProtocol.

2. “Why not Alamofire?”

For our scope, async/await + URLSession is enough. Alamofire adds a dependency for marginal benefit — interceptors, certificate management, multi-part uploads we don’t need. The capstone is partly about showing I can build the layer; pulling Alamofire defeats that. For a real product with heavy file upload or multipart form-data needs, Alamofire is a fine choice.

3. “How does cert pinning work?”

URLSessionDelegate.urlSession(_:didReceive:completionHandler:) fires for the TLS handshake. I evaluate the server trust normally, then walk the cert chain, extract the public key from each cert via SecCertificateCopyKey, hash it with SHA-256, and check against my hardcoded set of base64-encoded SPKI hashes. If any cert in the chain matches a pin, accept; otherwise, cancel the connection. I pin two hashes — the leaf and the backup intermediate — so cert renewals don’t break the app.

4. “How do you store the auth token?”

Keychain, with SecAccessControl flags .userPresence and kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. The .userPresence flag means the token is gated by a recent device unlock — Touch ID, Face ID, or passcode. WhenPasscodeSetThisDeviceOnly means the item is non-syncable and only readable if the device has a passcode. If the user has no passcode set, the item can’t be read — which is fine; we then prompt the user to set one or fall back to a less secure tier.

5. “What if the API token expires while the app is in use?”

The 401 path. APIClient catches a 401, asks AuthProvider.refresh(), retries the original request once. If refresh fails (refresh token also expired), it throws .unauthorized and the UI signs the user out. There’s no infinite retry — exactly one refresh attempt per failed call. Race condition: if multiple requests fail with 401 simultaneously, they shouldn’t all trigger refresh. AuthProvider.refresh() deduplicates by holding the in-flight refresh Task and returning the same task for concurrent callers.

6. “How do you handle subscription validation server-side?”

For ShopKit’s scope I rely on on-device Transaction JWS verification, which Apple signs and StoreKit 2 verifies for me. For a higher-revenue app I’d add server-side validation via the App Store Server API — the server checks subscriptions for the user’s original transaction ID, validates expiration, and acts as the source of truth. That prevents jailbroken devices from spoofing. I documented this in the ADR as a future enhancement.

7. “What happens if a user gets a refund?”

Transaction.updates yields a transaction with a non-nil revocationDate and a revocationReason. SubscriptionWatcher maps it to .revoked(reason:). The next time the user opens a Pro article, the gate rejects them. They see a message: “Your subscription was refunded; tap to view options.” I learned this is critical — Apple expects you to act on revocation within hours, and dragging your feet here is a real business risk.

8. “Tell me about your CI pipeline.”

GitHub Actions on macos-14. Every PR runs xcodebuild test — about 12 minutes including resolve. Merge to main triggers the deploy job: Fastlane match pulls certs from a private repo, gym builds, pilot uploads to TestFlight. Build numbers auto-increment via agvtool. Total merge-to-TestFlight time is about 25 minutes. Secrets are GitHub repo secrets (App Store Connect API key, match passphrase). Slack webhook notifies the team on success.

9. “What about Xcode Cloud?”

I considered it. Xcode Cloud has tighter Apple integration (signing managed for you) and lower configuration overhead. The downsides are vendor lock-in and less flexibility for non-Apple steps (running a custom linter, syncing to S3). For a portfolio piece I chose GitHub Actions because the configuration lives in the repo, and reviewing it tells you more about the developer’s CI knowledge.

10. “How would you test the purchase flow without using real money?”

Three layers. (1) Xcode StoreKit Configuration file — local, no Apple involvement, instant. (2) Sandbox testing on TestFlight with a sandbox Apple ID for end-to-end against real Apple infrastructure. (3) UI tests against the StoreKit Configuration file in CI, asserting the paywall renders products and the state machine flips after a simulated purchase. The first catches 90% of integration bugs; the second catches the edge cases (refund, billing retry); the third prevents regressions.

11. “Walk me through an App Store rejection you got.”

First submission was rejected for “missing functionality” — the reviewer couldn’t find the Sign in with Apple flow because the onboarding’s skip button wasn’t obvious. I clarified in App Review Notes, added a more prominent label, resubmitted. Approved on the next round. The lesson is that the reviewer doesn’t read your code; they spend 10 minutes tapping around. Make the happy paths impossible to miss.

12. “How would you scale this to multiple platforms?”

The shared modules — ShopKitCore, ShopKitAPI, ShopKitAuth, ShopKitStore — are pure-Swift, no UIKit. They compile on macOS, watchOS, tvOS with no changes. ShopKitUI would need a platform-specific equivalent. StoreKit 2 is cross-Apple-platform, so subscriptions work everywhere. The cross-Apple-platform story for ShopKit is straightforward; the cross-Android story requires a backend that issues its own entitlements based on Google Play transactions — different and out of scope for this capstone.

Red-flag answers

If asked “what’s the hardest thing about subscriptions,” don’t say “the API is complicated.” Say: “Handling state transitions when the app isn’t running — refunds, billing retries, grace periods — and getting the UI to reflect them within the SLA Apple expects.”

If asked “did you have any bugs in production,” don’t say “no.” Say: “Yes, two. The refund-honoring window I mentioned. And one where my Keychain item used the wrong accessibility flag and broke for users who hadn’t set a passcode. Both caught within a week via my crash + analytics signal; I have postmortems in the repo.”


Next: Capstone 4 — NoteSync.