Platform Notes (non-normative appendix)
Platform Notes (non-normative appendix)
This appendix records the Apple Wallet and Google Wallet behavior that shaped the requirements in the specification, as met by one production deployment of it, together with the lessons that deployment learned. Nothing here is normative. Nothing here identifies the deployment or its product: the product-specific record (its stack, its contract, its own action economics) lives with that product’s code, not in these assets. What remains is the part every implementer meets, because the platforms impose it.
On-chain interface
passURI. Reverts for a nonexistent token, otherwise returns an owner-settable base followed by the decimal token id. The base encodes the chain and contract, in the form <origin>/wallet-pass/eip155/<chainId>/<contract>/, so one server can serve several collections and networks without ambiguity. supportsInterface returns true for 0xef5f1e71.
PassUpdate. Emitted from the token’s transfer hook, so mint, transfer, and burn are all covered without separate call sites. Where pass-rendered state lives in a separate application contract, the application contract asks the token contract to emit the event through an emit-only signal function, callable only by the application contract, the owner, and registered signalers, because the specification places the events on the token contract. Each action taken from a pass, and each payment to a single token, emits PassUpdate for that token.
BatchPassUpdate. Emitted for collection-wide changes over the full minted range, and once per distribution over the inclusive range from the lowest to the highest affected token id. Unaffected ids inside that range receive a redundant refresh, which is cheaper than one event per token.
Signals never block state changes. Every signal call is wrapped so that a failure to emit cannot revert the action or settlement that caused it.
Passive state has no event. State that changes with time rather than by transaction (a deadline lapsing) emits nothing. The pass covers it with a relative date field that the device counts down on its own.
Gas. Signaling added about 4,100 gas per state-changing action and about 1,200 per mint in the deployment measured.
Push remains server-driven. The server still triggers APNs pushes and Google Wallet updates from the state changes it observes. The events serve other pass distributors and indexers, which have no access to that server.
Manifest endpoint
Gated resolution. An unauthenticated request to the passURI address returns 401 with a JSON body whose error is proof_required and whose challenge is the URI of the challenge endpoint for that token, and no acquisition URL. A request that proves control of the owning account returns 200 with the manifest (version, formats.apple a capability .pkpass URL, formats.google a Save to Google Wallet link, and updatedAt), sent with Cache-Control: no-store.
Proof of control. Either of two proofs is accepted. The first is an ERC-4361 challenge fetched from a challenge endpoint beneath the passURI address, signed, and returned in the Wallet-Pass-Proof and Wallet-Pass-Signature request headers. It follows the recommended serialization: the first resource is the CAIP-19 identifier of the token, the second is urn:wallet-pass:action:acquire, and the statement names the acquire action, with a single-use nonce, an expiration, the domain line, and the claimed account in the address line. The second is a session token from the embedded signer used by email-onboarded holders.
Rotation at first claim. When a proven owner is not the owner of record, the server rotates the per-pass secret before minting any acquisition URL. This is the gated configuration’s rotation upon the new owner’s first claim.
No metadata mirror. The optional wallet_pass property in token metadata is deliberately not served. tokenURI is public, so mirroring a gated manifest there would publish its capability URLs.
Apple Wallet
Signing. Passes are signed with a Pass Type ID certificate issued by Apple, chained to the Apple WWDR G4 intermediate. The certificate is issued to an enrolled developer organization; Apple does not review individual passes or pass designs. The certificate’s subject carries the Pass Type ID and the team identifier, which is what the manifest’s optional issuer member and the on-chain commitment name.
PassKit web service. The standard pass web service endpoints are served: device registration and unregistration, the list of serials updated since a tag, the latest pass for a serial (honoring If-Modified-Since with 304: Apple’s endpoint reference documents only the 200 and 401 responses, but the device sends the header, expects the 304, and logs a server that ignores it), and the device log endpoint. Each pass carries its own authentication token, compared in constant time. The device log is the signal to watch: Wallet reports a push after which the pass was unchanged, a fetch answered with the full pass where 304 was due, and a push to a device that holds no registration, and iOS 27 warned holders that automatic updates for the issuer’s passes would be disabled after a run of them. A forced hourly refresh that moved a global version for every pass produced about 27 downloads per device per day, over a third of them unchanged, and one holder’s phone stopped fetching while the card showed a state ten landed actions old. Pushes are now gated on a content change, If-Modified-Since is honoured, and the roster is refreshed on start only when the pass template changed; device log errors fell from about fifty an hour to about one.
Push. When rendered state changes, the server sends an empty-payload APNs push (apns-push-type: background, priority 5, topic set to the Pass Type ID) over HTTP/2, authenticating with the pass certificate as the client certificate. The device then fetches the new pass from the web service. Replacing the runtime’s default CA store with the WWDR certificate breaks the APNs TLS connection; add WWDR to the pass signing chain only.
Notifications. As documented for Wallet, a field change raises a lock-screen message only when that field carries a changeMessage (its %@ is replaced with the new value), and a device compares an updated pass with the copy it holds, not with what the server last sent. The following was confirmed on iOS 27 in October 2026, checking each step against a server log of exactly which fields each device received: changing the value of an existing plain-text field that carries changeMessage raised exactly one notification with that text, even though the iOS 27 poster dictionary repeats the field under the same key; adding a field without a changeMessage raised the generic “Pass changed”; removing a field, dropping a changeMessage while keeping the value, and changing a value without a changeMessage raised nothing; and a changeMessage on a field with attributedValue displayed the raw link markup, because the substituted value came from attributedValue. Announce by changing the value of a field every pass already has, never by adding a field; keep every other field stable across failed reads and restarts so no update adds one by accident; and test any notifying change on one device before sending it to every holder. Adding a field that carries a changeMessage in an update where other text also moved raised the generic “Pass changed” on some phones, so each reminder is best sent as an update whose only visible change is the value of one existing field with changeMessage “%@”.
Live countdown without push. Date fields with isRelative set are redrawn by the device as a live countdown, so a timer on the pass stays correct between pushes. relevantDates (iOS 18 and later: a startDate and endDate, or a single date from which Wallet derives a window) surfaces the pass on the lock screen; the older single relevantDate key is deprecated.
Back-of-pass action links. Each state-changing action is a link on the back of the pass, and each link is a capability URL bound to the pass and the action. Field text can change between pushes, but the link set is stable for a given owner. The back of a pass is visible in screenshots and screen recordings, which is one reason the specification asks for short link text, Referrer-Policy: no-referrer, and noindex on the pages those links open. With iOS 27, a pass of any style may also carry up to two Featured Actions: a top-level featuredActions array of objects with an identifier, a type from Apple’s list of action types (membershipBenefits, viewOffersRewards, place, call, order, shop, addToBalance and others) and a url, which Wallet draws beneath the pass face as cards with an icon and a localized call to action, in priority order. A capability link placed there is on the front of the pass and in every screenshot of it, the exposure the specification’s on-screen consideration describes; and the call to action is Apple’s wording for the type, not the issuer’s, so the type names what the holder gets rather than the action’s effect.
sharingProhibited. Setting it hides the share control in Wallet. It does not stop the .pkpass file from being forwarded by other means, so it does not change the bearer-artifact analysis.
Change messages and expiry, per Apple’s guidelines. The Human Interface Guidelines say change messages are for time-critical information only and never for marketing, which is the rule the deployment adopted after its own announcements cost it goodwill. Wallet hides a pass once its expirationDate has passed or it is voided, so a superseded pass could be filed away by setting one of them; the deployment keeps the keepsake visible instead, because the content is what tells the holder why the card changed. Apple’s authoring tools, Pass Designer (a macOS app that previews a pass with the device renderer and emits a .pkpasstemplate bundle) and Pass Builder (a Swift on server package that builds and signs passes), change nothing in the web service contract.
Superseded passes on transfer. After a transfer is claimed, the previous owner’s device stays registered, marked superseded: the web service still lists the pass for it and answers the old authentication token with a keepsake pass marked as transferred, carrying no action links, instead of the current pass. A superseded registration no longer holds one of the pass’s registration slots, and it stops receiving pushes after a short window (24 hours as deployed), so a forwarded copy cannot keep the holder’s own devices out and old copies fall quiet. The new owner receives a fresh pass under a rotated token. This is how the deployment meets the requirement that superseded passes not present themselves as current. The authentication token an installed pass carries has to stay valid for this to work: the keepsake reaches the old device only through the web service, which the device calls with that token. A reset that rotates the authentication token together with the per-pass secret leaves the pass already in Wallet unable to fetch again, and it freezes on its last content, showing a token as current days after its state changed, with nothing on the card to say so; this was observed in production after an owner’s link reset. Rotate the per-pass secret that derives links, leave the authentication token alone, and supersede registrations rather than delete them: deleting one races the device’s list call after the final push, and the card never flips.
Field limits. Wallet renders at most four secondary and auxiliary fields combined and truncates long header labels. Layouts that exceed this lose fields without an error.
Other surfaces. On macOS, passes open and their links work, but push updates were not observed. A pass added on one device mirrors to the holder’s other devices.
Google Wallet
Issuer and class approval. The issuer account must be approved, and a pass class is reviewed and approved by Google (reviewStatus of approved through the Wallet REST API). Until approval, saved passes carry a test-only marker.
Issuer id. The Wallet REST API requires the numeric issuer id. The alphanumeric merchant id shown elsewhere in the console is rejected, which surfaced as silently failing object writes. The numeric id prefixes every object id the account can create, which is what the manifest’s optional issuer member and the on-chain commitment name for Google.
Thin save links. A save link is a signed JWT (typ: savetowallet) with an origins list naming every origin that hosts the save button; a page origin outside that list is refused. Embedding the full class and object in the JWT produced links of about 2,400 characters, past the roughly 1,800-character practical limit, and adds failed intermittently with generic errors. Upsert the object through the REST API and issue a thin JWT that references the object by id.
One holder per object. A class’s multipleDevicesAndHoldersAllowedStatus decides whether an object can be saved by many accounts (MULTIPLE_HOLDERS, the default) or by one account on any of its devices (ONE_USER_ALL_DEVICES: once the first account saves it, no other account can view or save it). The single-holder setting makes a forwarded save link dead after the first save, which closes the saving side of the forwarded-pass hole on this platform; it does not stop a screenshot and it does not retire the links in a pass already saved, so rotation on transfer is still needed. The deployment described here ran the default, which is why its rotation issues a new object rather than patching the shared one. saveRestrictions.restrictToEmailSha256 goes further and binds a save to one email address (the hex SHA-256 of the lowercased address with dots and plus suffixes removed); Google asks issuers to contact support before using it.
Screenshots and unlock. passConstraints.screenshotEligibility: INELIGIBLE refuses screenshots of the pass on current Wallet versions (older versions may still allow them), and viewUnlockRequirement: UNLOCK_REQUIRED_TO_VIEW requires the device to be unlocked each time the pass is viewed. Both narrow the on-screen exposure of capability links on Android; Apple Wallet offers neither.
Upsert and updates. Objects are created with GET, then POST on 404, then PATCH when present. A 409 on POST means another request created the object concurrently; the correct response is to PATCH, not to skip, because the content being written may be the newer state. Later updates are PATCH writes guarded by a content hash. A change to structure alone (for example adding a module) must still force a write. Where several writers can touch one object, send the current ETag in If-Match on a read-modify-write (or If-Match: * to force the write), and batch requests cut connection overhead when many objects change at once.
Rotation moves to a new object. Every account that saved a Google Wallet object shares it, so a PATCH reaches every copy, a forwarded one or a previous owner’s included. When the per-pass secret rotates (a new owner’s first claim, or the owner’s reset), the fresh action links are therefore issued in a new object with a new random id, and the object that carried the old links is expired: its state is set to EXPIRED, its link list is replaced with only the public token page (a PATCH merges fields, so the list must be sent rather than omitted), and it carries the same keepsake wording as the Apple pass. Patching the old object in place would deliver the fresh links to every copy of it. Tokens that have never rotated keep their original object, so passes saved before this behavior shipped keep updating.
Notifications. A message added with addMessage always lands on the card, but it raises a phone notification only when its messageType is TEXT_AND_NOTIFY; a TEXT message is silent. Send deadline reminders as TEXT_AND_NOTIFY and everything else as silent PATCH writes. Google caps notifying messages per object per day, three at the time of writing; past the cap addMessage fails with a quota error rather than dropping silently, so keep a TEXT fallback, and Google also throttles an issuer it judges to be spamming its users. A message carries an id and a displayInterval, an object holds at most ten, and messages are edited or removed with PATCH. For a pass with a validity interval Google offers its own opt-in alerts, upcomingNotification a day before the interval starts and expiryNotification two days before it ends (only one applies, and expiry wins); the text and timing are Google’s, not the issuer’s. Messages accumulate: each addMessage appends an entry with its own id, and a card that receives a reminder a day ends up listing every reminder it ever received, stale ones included. Send the message list with only the current message, give each an expiry, and clear a reminder once the action it asked for has landed; a one-time cleanup once ended over a thousand stale lines across a few hundred cards.
Save callback. When a holder saves or deletes an object, Google POSTs a callback to the HTTPS URL in the class’s callbackOptions.url, with a User-Agent containing Googlebot or Google-Valuables; robots.txt must not disallow that path for Googlebot. The body names the class, the object, the event (save or del), an expiry (expTimeMillis) and a nonce. It is signed (protocol ECv2SigningOnly, sender GooglePayPasses, recipient the issuer id, Google’s public keys published at pay.google.com/gp/m/issuer/keys; Tink’s PaymentMethodTokenRecipient verifies it), so verify the signature before acting on it, drop duplicates by nonce, and drop messages past their expiry. Delivery is best-effort with retries. The callback carries no account identity and it is not entitlement: record it as display state only (whether to show the holder an add-to-wallet prompt), and record it from the first day; a deployment that only logged it could not later tell which holders had a card when that question mattered.
Android Expressive Passes (September 2026). Google introduced new Android layouts for its pass types, generic passes included; the web rendering keeps the earlier layout, so a pass should be checked on both. In the default template a wide logo replaces the header’s logo and issuer name on Android, links render grouped by kind (map coordinates, telephone numbers, email addresses, then web pages), at most 20 text modules and 20 messages are shown per level, and Google does not guarantee the order of messages, one more reason to keep a card to a single current message.
Tappable links. Google Wallet does not linkify text fields. The only tappable surface is linksModuleData, so action links live there.
Images. Image URLs are cached aggressively; changing the image requires a new URL. Hero images are wide, and a square image placed there is stretched. An image that should not sit at a public URL (a holder’s portrait) can be uploaded as a secure private image and referenced by privateImageId in imageModulesData; one private image serves one object, and the hero and logo slots still need public URLs.
Acceptable use. Google’s Acceptable Use Policy for the Pay and Wallet APIs prohibits cryptocurrency products and services (it names storage wallets and trading, with an exception for regulated fiat on- and off-ramps), limits gambling passes to non-monetary ones, and requires financial services to be licensed; it does not mention NFTs. A pass that projects a token’s state is not a wallet, which is the distinction the specification draws, and a pass that displays balances or moves value should be checked against the policy before publishing access is requested.
Authorization as deployed
- Fresh read. Every state-changing request (action taps, session key installation and revocation, pass link issuance, claims, challenge verification, and manifest resolution) reads
ownerOfat request time at thelatestblock. On failure it retries once after 250 ms, then refuses with a retryable503(Retry-After: 5). It never falls back to a cached or last-known-good owner; only read-only views use the cache. Making this strict also closed a path where a claim could proceed on the owner of record when the chain read failed. - Pass action links. Holders onboard by email and are never prompted per action, so no per-action signature exists at tap time: the capability configuration. Each link is a capability URL bound to the pass and the action. A tap resolves the link, performs the fresh read, and submits the action with a session key the owner authorized once with a signature. The session key’s authority is scoped on chain to the single state-changing function the pass exposes, and what that function can spend from the token’s own account is capped on chain by a per-transaction amount and a rolling daily allowance fixed when the spender is appointed. The forwarding residual the specification discloses therefore stays bounded: a forwarded link can repeat that one action for the owner, up to the allowance or the account’s balance, and cannot transfer, burn, or approve the token, change who may act for it, or reach any other function. Rotation on request is the remedy.
- Fallback. When no usable session key exists, the link redirects to a page where the owner signs the action directly.
- Rotation on request. The owner can reset the pass links at any time from the account page. It is the transfer rotation with the owner unchanged: the per-pass secret rotates, every action link and pass download URL derived from it stops verifying, the previous Apple registrations are superseded and refresh into a keepsake marked as reset (they no longer count toward the registration bound, so a reset always frees the slots), the Google object that carried the old links is expired and the rotated links are issued in a new object (see Rotation moves to a new object), and fresh acquisition URLs are returned. Limited to one reset per token per ten minutes. This is the remedy for a leaked link under an unchanged owner, which no transfer-driven rotation can provide.
- Transfer. The transfer is an ERC-721
transferFromsigned by the owner’s own account, never by the action session key, whose authority is scoped to the action function and cannot move the token. Where transfers are gas-sponsored, the sponsorship webhook funds one only when it matches a transfer the server prepared for that owner, token, and recipient, and only when a fresh read shows the signer is still the owner; it refuses every other transfer, approval, or operator call on the token contract. Landing the transfer revokes the giver’s session key at once; the pass secret rotates when the recipient first claims, with a signature over a server-issued single-use nonce, which invalidates every action link and pass download URL derived from it and triggers the keepsake behavior described above. - Approvals from the token’s account. An account implementation that does not refuse approvals granted from the token’s account lets an approval granted under one owner survive a sale, as the specification’s Security Considerations describe. No pass link can grant one. An account implementation that refuses approval-granting calls in its execution function while still allowing revocations closes this for tokens whose accounts are deployed after it ships; existing accounts behind immutable proxies keep the old behavior.
Lessons
- Pass download URLs must be capability URLs too. An early build served the pass file at a path keyed only by the pass serial, and the serial was public through token metadata. Because the pass embeds already-signed action links, anyone could download any holder’s pass and replay its links. This was found in internal review before real value was at stake, and there is no evidence it was exploited. The fix derives a download capability from the per-pass secret with an HMAC, verifies it in constant time, returns
404on mismatch, rotates it with the secret on transfer, and makes serial-only routes fail closed. - GET must be side-effect free. Mail scanners, chat previews, and wallets prefetch links. An action link that acts on GET gets triggered by a crawler. Answer GET with a page that submits the action by POST, and act only on POST.
- Browser storage opened from a pass is ephemeral. Links open in an in-app browser whose storage does not survive from one tap to the next, so a browser-local session key re-prompted on every action. Holding the scoped session key server-side fixed it.
- Numeric issuer id, not merchant id, for the Google Wallet REST API.
- Respect field-count limits on Apple passes, and put every Google link in
linksModuleData. - Treat a Google
409as “exists, now PATCH”. - Keep WWDR out of the runtime CA store when connecting to APNs.
- Notification throttling must not swallow time-critical warnings. Quiet-hour suppression is fine for routine updates and wrong for warnings whose deadline falls inside the quiet window.
- Report a refusal a retry cannot fix as a refusal. A holder withdrew the whole balance of their token’s account, and every action then failed under a generic “try again” message. The token came within the hour of its deadline before the cause was found, with ample balance sitting in the holder’s own account. Check the token account’s balance before submitting an action, name the condition when it is short (
insufficient_balance), and offer a one-tap top-up. - A warning must name every action that is due. Where a token has several deadlines that often lapse together, a warning that names only the first leaves holders acting on one need repeatedly while another lapses; most of the tokens that lapsed after their holders had returned at least once lapsed on a need the holder had never acted on. Name every action due within the warning horizon, most urgent first, say how long the token has, and say where on the card to tap.
- Social apps’ in-app browsers do not add passes. Holders who minted inside a social app’s in-app browser did not add their pass there. A receipt email carrying both wallet buttons is the path that reaches them, as it is for holders who mint on a desktop.
- Surface the pass on the lock screen only when it matters.
relevantDatesis used for the final hours before a deadline, never as a daily reminder, so that a holder who sees the pass on the lock screen knows it needs them now. - A restart must not delay a deadline reminder. A reminder engine that runs on a fixed interval with no first run pushes the next check back by up to a full interval on every deploy; on a night with several deploys one final warning went out 13 minutes late. Run one check shortly after every start, and hold reminders for a short window after a landed action so a holder who just acted is not warned for what they just did.
- Rotate the link secret, never the pass’s update credential. A pass learns it was superseded only through its platform update path. Revoking that credential in the same step as the link secrets, as an owner’s reset did in one deployment, froze the installed pass on its last content with no way to tell its holder. Keep the two secrets separate, and supersede device registrations instead of deleting them.
- Expect every action to arrive twice. On iOS, a link opened from a pass is fetched with GET, then the POST the page submits is aborted by the in-app browser and sent again, so the server sees two POSTs for every tap. Charging twice and refusing the second as a replay are both wrong; the second is the same request. Treat an identical request that arrives while the first is in flight, or within seconds of its completion, as that request, and answer it with the same outcome; before this, every holder saw a spurious waiting page on every tap.
- The pass store is unrecoverable state. An installed pass can reach its issuer only with the credential it was issued with, and nothing can re-point it at a new one. Lose the store that holds those credentials and every installed pass is orphaned: it keeps its last content and can never be updated or superseded. Back the store up, verify the restore, and keep the platform signing material separate from it.