# 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 two 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. `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** (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`, `sharingProhibited`, 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/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 | 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). | | `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. | | `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/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/..." } ``` All fields above are accepted on a single create call; this minimal example is the shape, not the limit. Worked examples (a full pass, Pro branding, JavaScript, Python) live in [llms-full.txt](https://walletwallet.dev/llms-full.txt), and the [docs](https://walletwallet.dev/docs) add more languages. 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**. ## 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` 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. 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. - `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 use in an `` button. - Hosted page: `GET /p/` is a public page that 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/` next to Apple's `devices[]`. - Layout: Google's generic card differs from Apple's, 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. Google passes use the Google Wallet generic class. ## 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/passes/ { "headerFields": [{ "label": "POINTS", "value": "1,300", "changeMessage": "Now at %@ points" }] } ``` The wallet substitutes `%@` with the new `value` at push time. ### Send a notification To push a custom lock-screen notification on demand, ship a `backFields` "notification anchor". Put the user's message in `value`, and set `changeMessage` to literally `"%@"`. The wallet substitutes `%@` with the new `value` at render time, so the banner reads whatever you wrote in `value`. Two mechanics make the seed-then-bump pattern necessary. The notification fires only when a field's *value* actually changes between pass versions. And your `changeMessage` text is honored only when it contains `%@`; without it, the banner falls back to a generic "Pass Changed" string. The anchor has to exist on the pass before you try to use it. Seed it on the POST that creates the pass: ```json POST /api/passes { ..., "backFields": [ { "label": "Notifications", "value": " ", "changeMessage": "%@" } ] } ``` Then on each send, bump `value` to the message you want and keep `changeMessage` as `"%@"`: ```json PUT /api/passes/ { ..., "backFields": [ { "label": "Notifications", "value": "Free coffee on us!", "changeMessage": "%@" } ] } ``` A few practical notes: - The anchor lives in `backFields` so it doesn't crowd the pass face. Customers only see "Notifications: " if they tap ⓘ to flip the pass. - Keep the `backFields` array order stable between POST and every PUT. Fields are identified by position; reordering the array re-keys the anchor and the banner stops firing on it. - If you skip the POST seed, the first PUT introduces the anchor as a brand-new field, which is treated as "no value change" and silently suppresses the banner. Subsequent sends work because the anchor now exists, so only the first is silent. - Sending the same message text twice in a row is a no-op. The value did not change, so no banner fires. Vary the text, or bump to something distinct, if you need a repeat. ## 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