# WalletWallet API > REST API that issues passes to BOTH Apple Wallet and Google Wallet from one request. Send JSON, get a signed Apple `.pkpass` binary plus a Save to Google Wallet link (the `X-Google-Save-Url` response header). No Apple Developer or Google issuer account needed: WalletWallet signs Apple passes with its own Pass Type ID and WWDR chain, and mints Google save JWTs from its own issuer. Live updates and push work on both wallets. The API has three write endpoints and one read endpoint. `POST /api/passes` creates a pass and returns a JSON envelope `{serialNumber, googleSaveUrl, applePass, shareUrl}` (shareUrl is a hosted, device-aware install page for the pass: it shows the right Add to Wallet button per device and a QR on desktop; the legacy `POST /api/pkpass` is the same handler but returns the `.pkpass` binary directly, with `X-Serial-Number`, `X-Google-Save-Url`, and `X-Pass-Url` headers). `PUT /api/passes/` updates an issued pass: every Apple device that installed it gets an APNs push and Wallet refreshes in place, and the Google object is updated and pushed at the same time. `DELETE /api/passes/` revokes an issued pass: Apple rebuilds it voided with a past expiration date and pushes the void to installed devices, and the Google object is set to expired, so the pass is invalidated on every device. `GET /api/auth/usage` returns the current month's counter. All requests use Bearer auth with keys formatted `ww_live_<32 hex chars>`. Unknown auth scheme, missing key, or wrong key returns 401. Two push models, one call. Apple is device-pull: the phone-home URL is signed into the `.pkpass` and the device pulls a fresh pass over the PassKit web service. Google is server-push: WalletWallet PATCHes the Google object and sends an Add Message notification. The one honest difference: Apple shows your field `changeMessage` on the lock screen, while Google's update banner is generic and your text lives inside the pass. Two plans gate features: **Free** and **Pro**. The Pro-only request fields are `color`, `logoURL`, `thumbnailURL`, `stripURL`, and `iconURL`. Everything else, `colorPreset`, all field arrays, `locations`, `organizationName`, `expirationDays`, `sharingProhibited`, PUT updates, works on Free. Usage resets on the 1st of each month (UTC). POST always counts on success; PUT counts only when the body actually changed (an unchanged PUT is a no-op, no push, no quota). The API only supports two PassKit styles today: **generic** (default) and **storeCard** (auto-selected when you pass `stripURL`). `eventTicket`, `boardingPass`, and `coupon` are not exposed. Background images are not supported on generic passes, use `thumbnailURL` for a photo on the pass face. ## Base - Base URL: `https://api.walletwallet.dev` - Auth header: `Authorization: Bearer ww_live_` - Content type for write endpoints: `application/json` - Errors are JSON: `{"error": ""}`. Status codes: `400` validation, `401` bad/missing key, `403` serial belongs to another key, `404` unknown serial or unknown route, `405` wrong method, `429` monthly limit hit, `500` server error. ## POST /api/passes, create a pass Returns the signed `.pkpass` file as `application/vnd.apple.pkpass`. The response carries these custom headers: - `Content-Type: application/vnd.apple.pkpass` - `Content-Disposition: attachment; filename=".pkpass"` (slug derived from `title` or `logoText`, lowercased, non-alphanumerics replaced with `-`) - `X-Serial-Number: `, the server-generated serial. CORS-exposed via `Access-Control-Expose-Headers`. Save it if you ever want to update the pass. The same value is also embedded in `pass.json` inside the bundle. - `X-Google-Save-Url: https://pay.google.com/gp/v/save/`, the Save to Google Wallet link for the same pass. Open it on Android to install. Legacy binary endpoint: `POST /api/pkpass` is the same handler but returns the raw `.pkpass` body by default (with the `X-Serial-Number`, `X-Google-Save-Url`, and `X-Pass-Url` headers). `?format=` overrides either default, so `/api/passes?format=binary` returns the binary and `/api/pkpass?format=json` returns the JSON envelope. The body sent in by the caller is never echoed; you only get the binary back. Including `serialNumber` or `authenticationToken` in the request body returns 400, both are server-owned. ### Request body fields At least one of `logoText`, `primaryFields`, or `title` must be present. | Field | Type | Plan | Required | Notes | |---|---|---|---|---| | `barcodeValue` | string | All | No | Optional. Data encoded in the barcode. Max 512 chars. Omit it (or send an empty string) for a pass with no barcode. | | `barcodeFormat` | string | All | No | Required only when you send a `barcodeValue`, in which case it must be one of `QR`, `PDF417`, `Aztec`, `Code128`. | | `logoText` | string | All | No* | Text next to the logo (top-left). Max 64 chars. | | `description` | string | All | No | Accessibility text, not visible. Max 128 chars. Defaults to `logoText` → `title` → `"Pass"`. | | `organizationName` | string | All | No | Issuer name shown as the lock-screen banner title on updates, on lock-screen location-relevance surfaces, in the iOS share sheet, and in Wallet's pass info screen. Max 64 chars. Falls back to the account default. | | `primaryFields` | array | All | No* | Main content. Up to 10 `{label?, value, changeMessage?}` objects. `label` is optional: omit it (or send an empty string) and the value renders alone with no label on both wallets. `label` ≤ 64 chars, `value` ≤ 256 chars, `changeMessage` ≤ 128 chars. | | `secondaryFields` | array | All | No | Below primary. Same shape and limits as `primaryFields`. | | `headerFields` | array | All | No | Top-right of pass, the only fields visible when the pass is stacked in Wallet. Same shape and limits. | | `backFields` | array | All | No | Back of pass (tap the ⓘ). Same shape and limits. iOS auto-detects URLs, phone numbers, emails, addresses and makes them tappable. | | `locations` | array | All | No | Up to 10 geofences. Each `{latitude, longitude, altitude?, relevantText?}`. `latitude` in [-90, 90], `longitude` in [-180, 180], `relevantText` ≤ 128 chars. Wallet surfaces the pass on the lock screen when the device is near a coordinate (uses significant-location-change service, surfacing can lag a minute or two). | | `sharingProhibited` | boolean | All | No | Hides the Apple Wallet share button. Defaults to `true` (best for loyalty and membership cards). Set `false` to let holders share the pass, e.g. event or athlete cards. | | `colorPreset` | string | All | No | One of `dark` (default), `blue`, `green`, `red`, `purple`, `orange`. | | `expirationDays` | integer | All | No | Pass expires `N` days from issue. 1–3650. | | `color` | string | Pro | No | Custom hex background, e.g. `#1e40af`. Foreground/label auto-derived from luminance. Overrides `colorPreset`. | | `logoURL` | string | Pro | No | Brand mark on the pass face (top-left). HTTPS URL or `data:image/png;base64,...`. Private/internal IPs rejected. Recommended 160×160 px, max 1MB. | | `thumbnailURL` | string | Pro | No | Small image, top-right of the pass face. Use for customer photos, product shots. HTTPS URL or PNG data URI. Recommended 180×180 px, max 1MB. | | `stripURL` | string | Pro | No | Wide banner image behind the primary field. **Setting this switches the pass to `storeCard` style.** HTTPS URL or PNG data URI. Recommended 1080×360 px, max 1MB. | | `iconURL` | string | Pro | No | Replaces the small square icon shown in iOS lock-screen notifications when the pass updates. Distinct from `logoURL`. HTTPS URL or PNG data URI. Recommended 120×120 px, max 1MB. | | `title` | string | All | No* | Legacy. If set without `primaryFields`, becomes `primaryFields[0].value` and also fills `logoText` when that's missing. Max 64 chars. | | `cardLabel` | string | All | No | Legacy. Sets `primaryFields[0].label` (defaults to `"CARD"` when omitted; an empty string means no label). Only used when `title` populates `primaryFields`. Max 32 chars. | | `label` | string | All | No | Legacy. With `value`, becomes `secondaryFields[0]`. | | `value` | string | All | No | Legacy. With `label`, becomes `secondaryFields[0]`. | \* At least one of `logoText`, `primaryFields`, or `title` is required. Size limits (each returns 400 when exceeded): every image is capped at 1MB (decoded), the request body at 2MB total, and the built `.pkpass` at 10MB. ### Example: minimal ```bash curl -X POST https://api.walletwallet.dev/api/passes \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ww_live_" \ -d '{ "barcodeValue": "MEMBER-12345", "barcodeFormat": "QR", "logoText": "Membership Card" }' # Returns JSON: { "serialNumber": "...", "googleSaveUrl": "https://pay.google.com/gp/v/save/...", "applePass": "", "shareUrl": "https://api.walletwallet.dev/p/..." } ``` ### Example: full pass with updateable field and location trigger ```bash curl -X POST https://api.walletwallet.dev/api/passes \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ww_live_" \ -d '{ "barcodeValue": "LOYALTY-98765", "barcodeFormat": "QR", "logoText": "Bayroast Coffee", "organizationName": "Bayroast Coffee Co.", "description": "Bayroast loyalty card", "primaryFields": [{"label": "CARD", "value": "Coffee Rewards"}], "secondaryFields": [ { "label": "POINTS", "value": "250", "changeMessage": "You now have %@ points" }, {"label": "TIER", "value": "Gold"} ], "headerFields": [{"label": "BALANCE", "value": "$25.00"}], "backFields": [ {"label": "TERMS", "value": "Points expire after 90 days of inactivity."}, {"label": "SUPPORT", "value": "https://example.com/help"} ], "locations": [ {"latitude": 37.331741, "longitude": -122.030333, "relevantText": "Welcome back to Bayroast"} ], "colorPreset": "dark", "expirationDays": 365 }' # Response is JSON: { serialNumber, googleSaveUrl, applePass, shareUrl }. # Grab the serial for later updates with jq: # curl ... | jq -r .serialNumber ``` ### Example: Pro branding (custom hex + brand assets) ```bash curl -X POST https://api.walletwallet.dev/api/passes \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ww_live_" \ -d '{ "barcodeValue": "VIP-001", "barcodeFormat": "QR", "logoText": "VIP Access", "primaryFields": [{"label": "PASS", "value": "VIP Access"}], "color": "#8B4513", "logoURL": "https://example.com/logo.png", "iconURL": "https://example.com/icon.png", "thumbnailURL": "https://example.com/member-photo.png" }' ``` ### Example: capturing the serial in JavaScript ```javascript const res = await fetch('https://api.walletwallet.dev/api/passes', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ww_live_', }, body: JSON.stringify({ barcodeValue: 'TICKET-789', barcodeFormat: 'QR', logoText: 'Event Ticket', primaryFields: [{ label: 'EVENT', value: 'Concert' }], }), }); if (!res.ok) throw new Error((await res.json()).error); const { serialNumber, googleSaveUrl, applePass, shareUrl } = await res.json(); // serialNumber: save for updates. googleSaveUrl: Add to Google Wallet link. applePass: base64 .pkpass. shareUrl: hosted install page to send users. ``` ### Example: Python ```python import requests import base64 r = requests.post( 'https://api.walletwallet.dev/api/passes', headers={ 'Content-Type': 'application/json', 'Authorization': 'Bearer ww_live_', }, json={ 'barcodeValue': 'ORDER-456', 'barcodeFormat': 'Code128', 'logoText': 'Order Pickup', 'primaryFields': [{'label': 'ORDER', 'value': 'Pickup'}], 'secondaryFields': [{'label': 'Order #', 'value': '456'}], }, ) r.raise_for_status() data = r.json() # { serialNumber, googleSaveUrl, applePass, shareUrl } serial = data['serialNumber'] with open('order.pkpass', 'wb') as f: f.write(base64.b64decode(data['applePass'])) ``` Note: Cloudflare's bot rules block the default `Python-urllib` user-agent at the edge. `requests`, `httpx`, `curl`, `node-fetch`, and similar libraries are fine. If using `urllib.request`, set a custom `User-Agent` header. ## PUT /api/passes/, update an issued pass Replaces the stored body for ``. If the new body's canonical hash differs from the stored one, the server bumps `last_modified`, invalidates the cached `.pkpass` blob, and updates BOTH wallets. Apple: fans out an APNs push to every device registered for that serial; iOS wakes, pulls a fresh `.pkpass` over the PassKit web service, and replaces the pass in place. Google: PATCHes the Google object with the new content and sends an Add Message push. The Apple lock-screen banner text comes from any field's `changeMessage` template (`%@` is substituted with the new value); without one, iOS shows the default "Pass Updated". Google's update banner is generic; the changed content shows inside the pass. Body shape is identical to `POST /api/passes`. Sending `serialNumber` or `authenticationToken` in the body returns 400. ### Response (200) ```json { "serialNumber": "8f4c3a2e-...", "lastUpdated": 1778538208273, "notifiedDevices": 3, "unchanged": false } ``` `lastUpdated` is a millisecond epoch integer. `notifiedDevices` is the count of registrations the push was fanned out to (does not guarantee delivery, that depends on APNs and the user's device state). ### Idempotent retries If the new body is byte-equivalent to what's stored (same canonical hash), the response is: ```json { "serialNumber": "8f4c3a2e-...", "lastUpdated": 1778538208273, "notifiedDevices": 0, "unchanged": true } ``` No push fires. No quota consumed. Safe to retry. ### Status codes specific to PUT - `400`, validation failure, or body includes `serialNumber` / `authenticationToken` - `403`, serial exists but belongs to a different API key (intentionally generic, does not leak ownership) - `404`, unknown serial - `429`, monthly quota hit (only changed-body PUTs count toward quota; unchanged PUTs do not) ### Example: update a points balance and trigger a lock-screen banner ```bash curl -X PUT https://api.walletwallet.dev/api/passes/8f4c3a2e-... \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ww_live_" \ -d '{ "barcodeValue": "LOYALTY-98765", "barcodeFormat": "QR", "logoText": "Bayroast Coffee", "primaryFields": [{"label": "CARD", "value": "Coffee Rewards"}], "secondaryFields": [ { "label": "POINTS", "value": "500", "changeMessage": "You earned %@ points" } ], "colorPreset": "dark" }' ``` The customer's lock screen shows: **You earned 500 points**. ## DELETE /api/passes/, revoke a pass Revokes a pass you issued: it is invalidated on every device it is installed on, on both wallets. Apple rebuilds the pass as voided with a past expiration date, so it greys out, drops its barcode, and files under the holder's expired passes; the change reaches installed devices on their next poll. Google sets the pass to expired and moves it to the holder's expired passes, which is effectively permanent. On both wallets the pass remains in the holder's wallet, marked invalid, until they remove it: revocation invalidates the pass and the platform stops honoring it. The legacy alias `DELETE /api/pkpass/` works the same way. No request body is needed; any body is ignored. ### Response (200) ```json { "serialNumber": "8f4c3a2e-...", "deleted": true, "googleRevoked": true, "notifiedDevices": 3, "lastUpdated": 1778538208273 } ``` `deleted: true` means the server revoked the pass and attempted the push, not that any device has updated yet. `googleRevoked` reports the Google result for this call (`true` on success, `false` if the Google update failed, omitted when Google is not configured). `notifiedDevices` is the count of Apple registrations the empty push was fanned out to. There is no un-revoke. ### Idempotent retries A repeat DELETE on an already-revoked pass is a no-op success: ```json { "serialNumber": "8f4c3a2e-...", "deleted": true, "alreadyDeleted": true, "notifiedDevices": 0 } ``` ### Status codes specific to DELETE - `401`, missing or invalid key - `404`, unknown serial, or a serial owned by a different key (revoke collapses both to 404 and never reveals another account's serial) - `429`, monthly quota hit ### Example ```bash curl -X DELETE https://api.walletwallet.dev/api/passes/8f4c3a2e-... \ -H "Authorization: Bearer ww_live_" ``` ## Share a pass Every created pass has a hosted install page. `POST /api/passes` returns its URL as `shareUrl` (the legacy binary endpoint returns it as the `X-Pass-Url` response header). It looks like `https://api.walletwallet.dev/p/`. Send that one link to a user by email, SMS, a chat message, or an "Add to Wallet" button on a confirmation page. The page detects the visitor's device and shows the right option: - iPhone: an Add to Apple Wallet button installs the signed pass directly. - Android: a Save to Google Wallet button adds the same pass to Google Wallet. - Desktop: a QR code so the visitor can scan it and add the pass from their phone. The page is branded from the pass itself (logo and color), so it looks like your pass, not a generic install screen. No app to install, nothing to host yourself. The link stays live for the life of the pass, and the same `` is the handle you use for `PUT /api/passes/` updates. ## GET /api/auth/usage Returns the current month's stats for the authenticated key. ```bash curl https://api.walletwallet.dev/api/auth/usage \ -H "Authorization: Bearer ww_live_" ``` Response (200): ```json { "count": 150, "limit": 1000, "remaining": 850, "resetDate": "2026-06-01", "plan": "free" } ``` `plan` is `free`, `trial`, `pro`, or `byok`. A new account starts on `trial`, which carries Pro's limits and features for 30 days and then reverts to `free` unless you subscribe; a live trial also returns `trialExpiresAt`. `resetDate` is the first of the next month (UTC). ## BYOK — bring your own Apple and Google credentials `byok` is a managed plan for customers who sign Apple passes with their own Pass Type ID and mint Google passes from their own issuer. Feature access matches Pro, with a much higher monthly operation cap. It is not self-serve: there is no purchase flow and it is never assigned automatically. Ask support to onboard you. Credentials are managed under `/api/credentials` (Bearer auth, `byok` plan only; every other plan gets 403). Apple private keys are never uploaded: `POST /api/credentials/apple/csr` generates the keypair server-side and returns a certificate signing request, you upload that to the Apple Developer portal, then post the issued `.cer` back to `POST /api/credentials/apple/certificate`. `GET /api/credentials` reports the status of each unit (Apple, APNs, Google) without echoing secret material, and `DELETE` removes one. Two BYOK behaviors differ from Free and Pro: - `applePass` is omitted from the `POST /api/passes` envelope when a `byok` account has configured Google but not Apple. On Free, on Pro, and on any `byok` account with an Apple unit, `applePass` is always present. - Deleting an Apple unit that still has live passes returns `409` unless you pass `?force=true`. After a forced delete those passes serve cached blobs and any rebuild returns `410 Gone`, which tells Apple Wallet to unregister the pass permanently. Claiming a pass type identifier or Google issuer id that another account already registered also returns `409`. An Apple unit with no APNs key signs passes correctly but can never push an update, so installed passes go stale with no error. Add the APNs key. ## Errors All non-2xx responses return JSON `{"error": "..."}`. Rate-limit responses additionally include `resetDate` and `message`: ```json { "error": "Rate limit exceeded", "resetDate": "2026-06-01", "message": "Monthly limit reached. Resets on 2026-06-01" } ``` Common validation messages: `barcodeValue must be 512 characters or less`, `barcodeFormat must be one of: QR, PDF417, Aztec, Code128`, `At least one of title, primaryFields, or logoText is required`, `color is only available on the Pro plan`, `expirationDays must be between 1 and 3650`, `locations[0].latitude must be between -90 and 90`, `URL must use HTTPS protocol`. ## Field-to-pass map What ends up where on the pass face. One `PassRequest` maps to both wallets; placement differs because each wallet has its own layout. - `logoURL` + `logoText` → Apple: top-left (brand wordmark). Google: logo top-left + cardTitle. - `headerFields` → Apple: top-right (the only fields visible when the pass is stacked). Google: a row on the card front. - `thumbnailURL` → Apple: top-right square slot (generic style only; conflicts with `stripURL`). Google: shows in the pass DETAILS view, not the front. - `primaryFields` → Apple: large center value. Google: first primary becomes the header + subheader (no subheader when the label is blank). - `secondaryFields` → Apple: below primary, side-by-side. Google: a column row on the card front. - `stripURL` → Apple: wide banner behind primary fields (switches style to `storeCard`). Google: rendered as a full-width hero image at the BOTTOM of the card. - `barcodeValue` + `barcodeFormat` → bottom of pass (both wallets). - `backFields` → Apple: flip side (tap ⓘ); iOS makes URLs/phones/emails tappable. Google: pass details view. - `iconURL` → Apple only: the icon in the lock-screen update notification and Wallet search. Not used by Google. - `organizationName` → Apple: title of the lock-screen update notification, share sheet, pass info. Google: issuer/brand. - `locations` → Apple: surfaces the pass on the lock screen (with `relevantText`) when near a coordinate. Google: maps to `merchantLocations` (geofence proximity notification, max 10), with no custom lock-screen text. NOTE: `relevantText` is Apple-only. ## How updates actually work Two mechanisms, one PUT. **Apple (device-pull):** the server bakes `webServiceURL` and a server-generated `authenticationToken` into every `pass.json`. On install, iOS calls `POST /v1/devices/.../registrations/...` to register, sending its APNs push token. When a PUT changes the body, the server fans out an APNs push (empty payload, a wake signal), iOS calls back, then pulls the new `.pkpass` and refreshes in place. **Google (server-push):** the server holds a Google issuer service account; on PUT it gets an OAuth token, PATCHes the Google object with the rebuilt content, and sends an Add Message push to every device that saved the pass. You don't implement any of this for either wallet; calling PUT is the whole integration. ## Google Wallet specifics - Install: `POST /api/passes` returns `googleSaveUrl` in the JSON body (the legacy binary `/api/pkpass` returns it as the `X-Google-Save-Url` header), a `https://pay.google.com/gp/v/save/` link. Opening it on Android adds the pass. `GET /api/passes//google` is a public 302 to that link for an `` button. - Hosted page: `GET /p/` shows the right Add button per device (Apple on iPhone, Google on Android) plus a desktop QR. No auth, the unguessable serial is the capability. - Install-state callback: `POST /webhooks/google-wallet` receives Google's signed save/remove events; the per-serial state surfaces in `GET /api/passes/`. - Layout: see the Field-to-pass map above (hero image at the bottom, thumbnail in details, secondary/header fields on the front, no lock-screen relevance text). - Field cap: with Google enabled, the fields mapped to Google's text rows (secondary, header, and back fields combined) are capped at 10; a body exceeding that returns 400. Apple has no equivalent combined cap. `locations` are still capped at 10 on both. ## Pass styles supported | API behavior | PassKit style produced | |---|---| | No `stripURL` | `generic` | | `stripURL` provided | `storeCard` | `eventTicket`, `boardingPass`, and `coupon` are not exposed today. ## Canonical docs - Full reference (HTML): https://walletwallet.dev/docs/ - Anatomy of a pass (deep-dive on every field, image slot, and the update loop): https://walletwallet.dev/blog/anatomy-of-an-apple-wallet-pass/ - Changelog: https://walletwallet.dev/changelog/ - Pricing & FAQ: https://walletwallet.dev/pricing/ - Signup / get an API key: https://walletwallet.dev/signup/ - Contact: alen@walletwallet.dev --- # Appendix: Anatomy of an Apple Wallet Pass > Background reference. This is the underlying PassKit model, image slots, field layouts, the update loop, color rules. The WalletWallet API maps onto a subset of it (see the field-to-pass map above for the supported subset). An Apple Wallet pass looks simple, a colored card with some text and a barcode. But under the surface, every pixel on that card maps to a specific JSON key in the `pass.json` file inside a `.pkpass` bundle. ## What is a wallet pass? A wallet pass is a digital card that lives in Apple Wallet (or Google Wallet). Boarding passes, loyalty cards, event tickets, coupons, membership cards, anything you'd carry in a physical wallet can become a pass. Technically, a wallet pass is a `.pkpass` file: a signed ZIP archive containing a `pass.json` (the data), image assets, a manifest, and a cryptographic signature. When a user taps "Add to Apple Wallet," iOS verifies the signature, reads the JSON, and renders the pass. What makes wallet passes more useful than a PDF or screenshot: - **They update after install.** A pass that points at a web service can be refreshed remotely, change a field on the server, every device with the pass installed pulls the new content within seconds. A flight gate change, a new point balance, a refreshed QR code. - **They notify.** When content changes, the device wakes via an Apple Push Notification and shows a banner on the lock screen, with text you control via the `changeMessage` template ("You earned 50 points"). - **They're location-aware.** A pass can carry up to 10 location triggers; Wallet surfaces it on the lock screen when the device is near one of them. - **They're scannable.** Built-in barcode rendering (QR, PDF417, Aztec, Code128) with no extra libraries. Users add passes by tapping a link, opening an email attachment, scanning a QR code, or through an in-app button. The file's MIME type (`application/vnd.apple.pkpass`) tells iOS to open it in Wallet. ## The pkpass file format Rename any `.pkpass` file to `.zip` and unzip it. Inside you'll find: ``` MyPass.pkpass/ ├── pass.json # All pass data, fields, colors, barcode, metadata ├── manifest.json # SHA-1 hash of every other file (integrity check) ├── signature # PKCS#7 detached signature over manifest.json ├── icon.png # Required. Shown in iOS notifications + search (29×29 pt) ├── icon@2x.png # Retina variant (58×58 px) ├── logo.png # Top-left of pass face (up to 160×50 pt) ├── logo@2x.png ├── strip.png # Optional. Wide banner behind primary fields (375×98–144 pt) ├── strip@2x.png ├── thumbnail.png # Optional. Small image, top-right of pass face (90×90 pt) ├── thumbnail@2x.png └── en.lproj/ # Optional. Localization folder └── pass.strings ``` A common point of confusion: **`icon.png` and `logo.png` are different things.** - **`icon.png`** is the small square shown in the iOS **lock-screen notification banner** when the pass updates, and in Wallet search results. It's the pass's "app icon." 29×29 pt at @1x. In the WalletWallet API: `iconURL`. - **`logo.png`** renders on the **face of the pass itself**, top-left. It's the brand wordmark. In the WalletWallet API: `logoURL`. - **`thumbnail.png`** is a square slot on the pass face, top-right, typically a customer photo, product image, or secondary brand mark. In the WalletWallet API: `thumbnailURL`. - **`strip.png`** is a wide banner that spans the top of the pass face on `storeCard`, `coupon`, and `eventTicket` styles. In the WalletWallet API: `stripURL` (switches the pass to `storeCard`). **Required files in any pkpass:** `pass.json`, `manifest.json`, `signature`, `icon.png` (plus `icon@2x.png` for Retina). Everything else is optional. **`manifest.json`** maps every filename to its SHA-1 hash. iOS uses this to verify nothing in the archive has been tampered with. **`signature`** is a PKCS#7 detached signature created with an Apple Developer pass-signing certificate plus the Apple WWDR intermediate certificate. This is what tells iOS "this pass was created by an authorized developer." **Image variants:** iOS picks the highest-resolution variant that fits the device. Provide `@2x` assets at minimum. `@3x` is supported for Plus/Max-sized phones. The maximum recommended size for a `.pkpass` file is around **1.5 MB**. ## Pass styles Every pass has a **style** that determines its layout and image slots. The style is the top-level JSON key: ```json { "formatVersion": 1, "generic": { "primaryFields": [...], "secondaryFields": [...] } } ``` The five PassKit styles: | Style | JSON key | Best for | Image slots | |-------|----------|----------|-----------------| | Generic | `generic` | Membership cards, IDs | logo, icon, thumbnail | | Store Card | `storeCard` | Loyalty cards, gift cards | logo, icon, strip | | Event Ticket | `eventTicket` | Concerts, movies, conferences | logo, icon, strip OR (background + thumbnail) | | Coupon | `coupon` | Discounts, promo codes | logo, icon, strip | | Boarding Pass | `boardingPass` | Flights, trains, ferries | logo, icon, footer | The WalletWallet API exposes **only `generic` and `storeCard`**. Pass `stripURL` to get a store card; everything else is generic. Style cannot change after issuing a pass (it's tied to the `passTypeIdentifier`). Each style has a distinct visual signature (boarding passes have a transit icon, coupons have a perforated top edge, event tickets have a small notch). These are baked into iOS and can't be customized. ## Front of the pass, field by field Field arrays in `pass.json` contain objects with `key`, `label`, and `value`. The `key` is an internal identifier (not displayed). The `label` appears as small uppercase text above the `value`. Any field can carry an optional `changeMessage`, a template iOS substitutes onto the lock-screen banner when this field's value changes during an update: ```json { "primaryFields": [{"key": "member-name", "label": "MEMBER", "value": "Jane Cooper"}], "secondaryFields": [ { "key": "points", "label": "POINTS", "value": "1250", "changeMessage": "You now have %@ points" } ] } ``` If `points` later goes from `1250` to `1500`, the banner reads "You now have 1500 points". Without `changeMessage`, iOS falls back to the generic "Pass Updated" text. `%@` is the substitution token. The WalletWallet API auto-generates `key`; you only supply `label`, `value`, and optionally `changeMessage`. ### Logo and logoText **Position:** Top-left of the pass. The `logo.png` renders in the top-left, followed by `logoText` (a string next to the logo). Together they identify who issued the pass. - Logo image up to 160×50 pt @1x (320×100 px @2x). Keep it legible at small sizes. - If you provide `logoText` but no logo image, the text still renders. - Watch the length of `logoText`, too long and it collides with header fields. Around 15–20 characters is a safe maximum. ### Header fields **Position:** Top-right of the pass. Header fields are special because they're the **only fields visible when the pass is stacked** in Wallet. When a user has multiple passes, they see a stack of cards with just the top sliver, logo, logoText, and header fields. This makes header fields the most valuable real estate on the entire pass. Use them for the single most important dynamic value: a point balance, flight status, event date, seat number. Up to **3 header fields**, but 1–2 is ideal. They share top-right space with the thumbnail image on generic and event ticket passes. ### Primary fields **Position:** The large, bold row in the center. Most pass styles support **1 primary field**. Boarding passes are the exception, 2, side by side with a transit icon between them. **Truncation:** Primary values truncate at roughly **20–25 characters** depending on font and device width. iOS does not wrap text; with too much text, fields may not display at all. Keep primary values short. ### Secondary fields **Position:** Below the primary fields. Multiple fields sit side by side, dividing the available width equally. Labels always appear above their values. **Capacity:** Up to **4 secondary fields** on most styles. On coupons, store cards, and generic passes with a square barcode (QR or Aztec), secondary and auxiliary fields share a combined limit of 4 total. ### Auxiliary fields **Position:** Below or alongside secondary fields. Not exposed in the WalletWallet API today. In PassKit: on boarding passes, up to **5 auxiliary fields** can appear. On other styles, they share the combined 4-field limit with secondary fields. Visually similar to secondary fields; the distinction is mainly layout order and how Apple Watch renders them (auxiliary fields appear after secondary fields, each on its own line). ### Strip image and thumbnail **Strip image** (`strip.png`): A wide banner that sits behind the primary fields. Supported on store cards, coupons, and event tickets. - Recommended size: 375×123 pt @1x for most styles. 375×98 pt for event tickets, 375×144 pt for coupons and gift cards. - Text renders on top of the strip, `foregroundColor` must contrast with the image. - On Apple Watch, the strip image is **not displayed**. - When you add a strip, the primary fields render on top of the image. Busy or bright images make text unreadable, use a darker image or overlay it with `backgroundColor` at reduced opacity. Because of this, **secondary fields become more important in strip layouts**, they sit below the strip on a solid background and are always legible. **Thumbnail** (`thumbnail.png`): Small image in the top-right area, next to the header fields. Supported on generic passes and event tickets (when not using a strip). - 90×90 pt @1x (180×180 px @2x). Aspect ratio can range from 2:3 to 3:2. - Good for: profile photo, product image, secondary brand mark. - Renders with rounded corners automatically. **You can't use both** strip and thumbnail on the same pass. In the WalletWallet API, sending `stripURL` switches the pass to `storeCard`, which has no thumbnail slot. ### Barcode **Position:** Bottom of the pass, centered. PassKit JSON key is `barcodes` (an array, iOS renders the first format it supports). The legacy singular `barcode` key was deprecated in iOS 9. The WalletWallet API generates both for compatibility. Four formats: | Format | PassKit value | Best for | |--------|-----------|----------| | QR | `PKBarcodeFormatQR` | Most use cases. Compact, fast to scan | | PDF417 | `PKBarcodeFormatPDF417` | Legacy systems, some event venues | | Aztec | `PKBarcodeFormatAztec` | Transit, some airlines | | Code128 | `PKBarcodeFormatCode128` | Retail POS, simple numeric codes | `altText` appears below the barcode as a human-readable string, useful when a scanner fails. (Not exposed in WalletWallet API today.) A pass can have **no barcode** in PassKit, and the WalletWallet API supports this too. `barcodeValue` is optional, and `barcodeFormat` is needed only when you send a `barcodeValue`. **NFC** uses a separate top-level `nfc` key, requires Apple's explicit approval, and is not exposed in the WalletWallet API. ## Back of the pass Tap the info button (ⓘ) on any pass to flip it over. The back is a scrollable list of fields, no layout constraints, no truncation limits. iOS auto-detects and makes tappable: - **URLs**, opens Safari - **Phone numbers**, opens Phone - **Email addresses**, opens Mail - **Street addresses**, opens Maps No HTML or markdown, plain text with smart detection. No practical limit on the number of back fields. The back is **not available on Apple Watch**, keep critical information on the front. ## How passes update after install A pass is not a static artifact, the same serial can carry different content over time. Apple Wallet's update model is a **wake-and-poll** loop, not a payload push. The pieces in `pass.json` that make this possible: ```json { "passTypeIdentifier": "pass.com.example.loyalty", "serialNumber": "8f4c3a2e-...", "webServiceURL": "https://api.example.com", "authenticationToken": "32-hex-chars-or-more" } ``` - `serialNumber`, your handle for "this specific pass." - `webServiceURL`, the base URL Wallet polls for updates. - `authenticationToken`, opaque shared secret. Devices send it back when fetching updates. What happens in practice: 1. **Install.** User adds the pass. iOS reads `webServiceURL` from `pass.json` and immediately calls `POST /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}` to register, sending its APNs push token. 2. **Issuer changes something.** Backend updates the pass content and bumps a "last modified" timestamp. 3. **Push notification.** Server fans out an APNs push to every registered device. The push payload is **empty**, just a wake signal. The lock-screen text doesn't come from the push body; it comes from any `changeMessage` field that changed. 4. **Device polls.** iOS calls `GET /v1/devices/.../registrations/...?passesUpdatedSince=` to ask "what's changed?" Server returns the list of changed serial numbers. 5. **Device fetches new bytes.** iOS calls `GET /v1/passes/{type}/{serial}` (with `Authorization: ApplePass `) and pulls a fresh `.pkpass`. Wallet replaces the pass in place. End-to-end loop takes ~1–3 seconds on a real iPhone. The WalletWallet API handles all of this. The integration is just `PUT /api/passes/{serial}` with the new content. ### Location triggers Wallet can surface a pass on the lock screen without a push, when the device is physically near a configured coordinate: ```json { "locations": [ { "latitude": 37.331741, "longitude": -122.030333, "relevantText": "Welcome to Apple Park" } ] } ``` Up to **10 locations** per pass. iOS uses its significant-location-change service, surfacing isn't instant when you cross the geofence; it's tied to normal location updates and can lag a minute or two. Good for: coffee loyalty at the storefront, stadium tickets at the venue, event coupons at the store. The optional `relevantText` (≤128 chars) is what the lock-screen banner reads when the surfacing fires. ## Colors and appearance Three color keys in `pass.json`: ```json { "backgroundColor": "rgb(30, 64, 175)", "foregroundColor": "rgb(255, 255, 255)", "labelColor": "rgb(191, 219, 254)" } ``` | Key | Controls | Format | |-----|----------|--------| | `backgroundColor` | Pass background | `rgb(R, G, B)` | | `foregroundColor` | Value text | `rgb(R, G, B)` | | `labelColor` | Label text (small text above values) | `rgb(R, G, B)` | **If you don't set colors**, iOS tries to derive them from the logo or strip image. The result is often acceptable, but unpredictable. Set colors explicitly for consistent branding. **Contrast adjustment:** iOS may adjust your colors if there isn't enough contrast between `foregroundColor` and `backgroundColor`. The WalletWallet API handles the math for you: - `colorPreset` picks from `dark`, `blue`, `green`, `red`, `purple`, `orange`, each preset's `bg`/`fg`/`label` are predefined for high contrast. - `color` (Pro) takes a hex like `#1e40af` and derives `fg`/`label` from luminance (white text on dark backgrounds, black text on light). **Common mistakes to avoid:** - `labelColor` the same as `foregroundColor`, labels and values blend. - Dark text on a dark background (forgetting to change `foregroundColor` when darkening `backgroundColor`). - Not testing on the lock screen, where ambient lighting differs.