# WalletWallet API > REST API that returns signed Apple Wallet `.pkpass` files. Send JSON, get a binary pass. No Apple Developer account or certificate setup — WalletWallet signs with its own Pass Type ID and WWDR chain. Same `.pkpass` opens in Google Wallet on Android (Google Wallet does not honor `webServiceURL`, so live updates are Apple-only). The API has two write endpoints and one read endpoint. `POST /api/pkpass` creates a pass and returns the binary plus an `X-Serial-Number` response header. `PUT /api/pkpass/` updates an issued pass — every iPhone, iPad, and Apple Watch that installed it gets an APNs push and Wallet refreshes in place. `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 plans gate features: **Free** (1,000 passes/month) and **Pro** ($19/month, 100,000 passes/month). The Pro-only request fields are `color`, `logoURL`, `thumbnailURL`, `stripURL`, and `iconURL`. Everything else — `colorPreset`, all field arrays, `locations`, `organizationName`, `expirationDays`, PUT updates — works on Free. New signups get a 30-day Pro trial. 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/pkpass — create a pass Returns the signed `.pkpass` file as `application/vnd.apple.pkpass`. The response carries three 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. 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 | Yes | Data encoded in the barcode. Non-empty, max 512 chars. | | `barcodeFormat` | string | All | Yes | 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` ≤ 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). | | `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. | | `thumbnailURL` | string | Pro | No | Small image, top-right of the pass face. Use for customer photos, product shots. HTTPS URL or PNG data URI. | | `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. | | `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. | | `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"`). 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. ### Example: minimal ```bash curl -X POST https://api.walletwallet.dev/api/pkpass \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ww_live_" \ -d '{ "barcodeValue": "MEMBER-12345", "barcodeFormat": "QR", "logoText": "Membership Card" }' \ -o membership.pkpass ``` ### Example: full pass with updateable field and location trigger ```bash curl -X POST https://api.walletwallet.dev/api/pkpass \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ww_live_" \ -D headers.txt \ -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 }' \ -o rewards.pkpass # Grab the serial for later updates: grep -i '^x-serial-number:' headers.txt ``` ### Example: Pro branding (custom hex + brand assets) ```bash curl -X POST https://api.walletwallet.dev/api/pkpass \ -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" }' \ -o vip.pkpass ``` ### Example: capturing the serial in JavaScript ```javascript const res = await fetch('https://api.walletwallet.dev/api/pkpass', { 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 serial = res.headers.get('X-Serial-Number'); // save this if you'll update later const blob = await res.blob(); // .pkpass binary ``` ### Example: Python ```python import requests r = requests.post( 'https://api.walletwallet.dev/api/pkpass', 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() serial = r.headers['X-Serial-Number'] with open('order.pkpass', 'wb') as f: f.write(r.content) ``` 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/pkpass/ — 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 fans out an APNs push to every device registered for that serial. iOS wakes, calls the WalletWallet update endpoints, pulls a fresh `.pkpass`, and replaces the pass in place. The 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". Body shape is identical to `POST /api/pkpass`. 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/pkpass/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**. ## 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` or `pro`. `resetDate` is the first of the next month (UTC). ## 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 is required and must be a non-empty string`, `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: - `logoURL` + `logoText` → top-left (brand wordmark) - `headerFields` → top-right (the only fields visible when the pass is stacked in Wallet — use for the single most important dynamic value) - `thumbnailURL` → top-right square slot (generic style only; conflicts with `stripURL`) - `primaryFields` → large center value (1 on generic/storeCard, 2 on boardingPass which is not exposed) - `secondaryFields` → below primary, side-by-side - `stripURL` → wide banner behind primary fields (switches style to `storeCard`) - `barcodeValue` + `barcodeFormat` → bottom of pass - `backFields` → flip side (tap ⓘ); iOS makes URLs/phones/emails tappable - `iconURL` → not on the pass face. Used as the icon in the lock-screen notification banner when the pass updates, and in Wallet search results. - `organizationName` → not on the pass face. Used as the title of the lock-screen update notification, on the location-relevance surface, in the iOS share sheet, and in Wallet's pass info screen. - `locations` → not on the pass face. Used by iOS to surface the pass on the lock screen when the device is near a configured coordinate. ## How updates actually work 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 — it's just a wake signal), iOS calls back to check what changed, then pulls the new `.pkpass` and refreshes the pass in place. End-to-end latency is typically 1–3 seconds on a real device. You don't implement any of this — calling PUT is the whole integration. ## Pass styles supported | API behavior | PassKit style produced | |---|---| | No `stripURL` | `generic` | | `stripURL` provided | `storeCard` | `eventTicket`, `boardingPass`, and `coupon` are not exposed today. ## Common patterns ### Update one field and let `%@` substitute the new value For a value-change update with a templated banner (e.g. "Now at 1,300 points"), put `changeMessage` on the field whose value is changing: ```json PUT /api/pkpass/ { "headerFields": [{ "label": "POINTS", "value": "1,300", "changeMessage": "Now at %@ points" }] } ``` iOS substitutes `%@` with the new `value` at push time. ### Send a custom banner message without changing a visible field Apple Wallet only fires a lock-screen banner when a field's *value* changes — `changeMessage` is the template, not the trigger. To send arbitrary message text without touching any field your user sees on the pass face, attach a hidden sentinel `backFields` entry and bump its `value` on every send: ```json PUT /api/pkpass/ { "backFields": [{ "label": "__NOTIFICATION", "value": "1747400000000", "changeMessage": "Free coffee on us! Come grab yours." }] } ``` Two rules: - The sentinel field must live in `backFields` so it doesn't show on the pass face (users never see "__NOTIFICATION"). - Use `Date.now()` (epoch ms) as the value, not a counter — guarantees a fresh value every send without you tracking state. The `changeMessage` is your literal banner text — `%@` would be substituted with the field's `value`, so omit it (or strip it from user input) when sending a custom message. ## 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