Family Sync With No Server: Apple-Signed Entitlements and MultipeerConnectivity

August 25, 2026 · Nick Liu, developer of ReceiptIQ · engineering notes

ReceiptIQ is a receipt-scanning expense tracker I build alone. Last year I added the feature users asked for most: share one subscription with your family, and keep everyone's receipts and budgets in sync. The obvious design is a backend — accounts, a database, sync endpoints. I shipped it with none of those. There is no server, no user account, and no copy of anyone's data outside their phones. This post is about how that works and what broke along the way.

Why no server

Three reasons. First, it's a finance app; the least interesting thing I could do with people's grocery receipts is hold them. Every privacy question disappears when the data physically never leaves the household. Second, I'm one person — a sync backend is an on-call rotation I'd be running alone forever. Third, it's a subscription app, and subscribers outlive servers: if I get hit by a bus, syncing between two phones on the same WiFi should keep working.

Problem one: proving someone paid, without a server

Family sharing means the owner's subscription must extend to family devices that never bought anything. With a backend you'd verify the receipt server-side and issue your own session tokens. Without one, the member device has to verify the owner's subscription itself, from data the owner's phone hands it — and that data must be unforgeable.

StoreKit 2 gives you exactly one thing that works here: VerificationResult.jwsRepresentation — the transaction as a JWS signed by Apple. The tempting alternative, Transaction.jsonRepresentation, is just JSON; anything on the wire could have written it. The JWS travels to the member device during pairing, and the member verifies it locally:

That last check has a fun consequence: TestFlight builds run in the Sandbox environment, so family pairing can never be tested through TestFlight — only debug builds accept Sandbox transactions. I learned that one the slow way.

Members re-verify the cached JWS on every launch, and the owner pushes a fresh one on every sync contact. If the owner cancels, every family device drops back to free once the cached proof expires — no server needed to enforce it, the math enforces it.

Problem two: pairing that a screenshot can't steal

Devices join by scanning a QR code on the owner's phone. The QR is deliberately boring: a family UUID and a one-time nonce, about 100 bytes. It contains no entitlement and grants nothing by itself — redeeming it requires a live MultipeerConnectivity handshake with the owner's device, on the same network, while the QR sheet is open, and the nonce dies after one use. Screenshot it, post it, nothing happens. The subscription proof travels only inside the encrypted session after the handshake authenticates with an HMAC over a family secret that lives in each device's Keychain.

Problem three: sync without a referee

With no server there's no authoritative timeline, so conflict resolution is last-writer-wins on a per-record modifiedAt, with tombstones for deletes. LWW is easy to describe and easy to get subtly wrong; these are the invariants that turned out to matter:

MultipeerConnectivity: the parts the docs don't emphasize

MC gives you discovery, sessions, and encryption for free, and then charges you in edge cases. What I'd tell past me:

The bug that shipped for two months

My favorite lesson came from my own second phone. Its "last synced" timestamp froze at a month old — while every sync visibly delivered all the receipts. The cause: when a device requests photos, the serving side silently skips any photo it can no longer provide (the receipt was deleted after the manifest was built, say). The protocol had no "unavailable" reply. The requester waited for a photo that would never come, its completion gate never opened, it never sent its final done — and a watchdog quietly failed a sync whose data had actually transferred fine. Every sync. For two months. The data always arrived, so nothing ever looked wrong.

The fix is a per-wave silence timeout on the requesting side: if no photo arrives for thirty seconds while some are still pending, treat the missing ones as unavailable and finish. That repairs the situation even when the other phone runs an old version — which is the constraint that makes protocol bugs in shipped apps genuinely fun: you can't fix the peer, only your own side's tolerance of it.

The general lesson is one I keep re-learning in different clothes: never treat an external party's silence as an answer. A peer that doesn't reply, an OCR pass that returns nothing, a StoreKit query that yields zero entitlements on airplane WiFi — every one of these once made the app confidently do the wrong thing until it learned to treat silence as "unknown," not "no."

Costs of this design, honestly

ReceiptIQ is on the App Store; family sharing covers up to four devices on one subscription. Happy to answer questions about any of this — the JWS verification and the LWW invariants are the parts I most wish someone had written up before I built them.