# 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 (Pro plan). 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, credentials, expirationDate}` (expirationDate is present only when the pass expires; 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`, `X-Pass-Url`, and `X-Expiration-Date` 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**. Pro covers live pass updates (`PUT` and the push it triggers), lock-screen location triggers (`locations`), featured action tiles (`featuredActions`), and the branding fields `color`, `logoURL`, `wideLogoURL`, `thumbnailURL`, `stripURL`, `backgroundURL`, and `iconURL`, and signing with your own Apple certificate and Google issuer (`credentials`, see "Your own certificates"). Free covers creating passes with `colorPreset`, all field arrays, `organizationName`, `expirationDays`, `expirationDate`, and `sharingProhibited`. 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 supports three PassKit layouts: **generic** (default), **storeCard** (auto-selected when you pass `stripURL`), and **posterGeneric** (added on top of either when you pass `backgroundURL`; iOS 27 and later draw the full-bleed poster, older iOS falls back to the classic key). `eventTicket`, `boardingPass`, and `coupon` are not exposed. ## 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`, `X-Pass-Url`, and `X-Expiration-Date` headers). Whenever the pass expires, the response carries `expirationDate`, the absolute UTC instant the pass was built with. It is there for `expirationDays` too, so an integration on the day count can read the date it resolved to and move to `expirationDate` when it needs a fixed one. `?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 1024 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`. | | `barcodeAltText` | string | All | No | Text under the barcode. Max 128 chars. Omit it to keep each wallet's default, which is nothing on Apple Wallet and the barcode value on Google Wallet. Send an empty string to show nothing on both. | | `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. | | `footerFields` | array | Pro | No | Up to 2 fields along the bottom of the poster layout. Same shape, but Apple draws the value only and ignores the label. Apple shows them with `backgroundURL` on iOS 27 and later; Google lists them in the pass details either way. | | `locations` | array | Pro | 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). | | `featuredActions` | array | Pro | No | Up to 2 tappable tiles under the pass face on iOS 27 and later, in priority order. Each `{identifier, type, url}`, except `place`, which is `{identifier, type, placeIdentifier}`. `identifier` unique within the pass, ≤ 64 chars. `type` is one of Apple's published action types: `viewSchedule`, `watchTrailer`, `listenToMusic`, `call`, `place`, `addToBalance`, `order`, `shop`, `membershipBenefits`, `bookAppointment`, `bookCar`, `bookFlight`, `bookStay`, `viewOffersRewards`. Wallet draws the icon and localized label from it (for example `membershipBenefits` renders "View Membership Benefits", `place` renders "Go to Location" and opens Maps), you supply no label text. Wallet hides every tile on the pass when any type is unknown, so other values are refused. `url` is a public HTTPS URL ≤ 2048 chars, or a `tel:` number for `call`; it is required for every type but `place`. `placeIdentifier` is the Apple Maps Place ID a `place` action opens, such as `I7C250D2CDCB364A` (letters and digits, from Apple's Place ID Lookup at developer.apple.com/maps/place-id-lookup/), required for `place` and refused on other types; a `place` action with only a URL is refused because iOS 27 draws no tile for it and warns it will reject the pass. On `place`, `url` is optional and only sets the Google Wallet link: a Google Maps share link opens the Maps app on Android. Google Wallet renders each action as a link button labeled from the type; a `place` links to its `url`, else to `https://maps.apple.com/place?place-id=`. iOS 26 and earlier ignore the key. | | `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 after the request that sets it. 1 to 3650. A PUT that sends it restarts the count from the update. Not allowed together with `expirationDate`. | | `expirationDate` | string | All | No | The exact instant the pass expires, as an RFC 3339 date-time with a UTC offset: `2027-01-31T23:59:00Z` or `2027-01-31T23:59:00+02:00`. A date alone, or a time without an offset, is refused. At most 10 years ahead. Must be in the future on create; on update a past date files the pass as expired on both wallets without revoking it. Stored and returned in UTC. Not allowed together with `expirationDays`. | | `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. | | `wideLogoURL` | string | Pro | No | Wide wordmark, 1280×400 px transparent PNG. Google Wallet shows it top-left and drops `logoText` and the round logo from the card. Apple Wallet uses it as the logo only when `logoURL` is absent, and as the lock-screen icon when `iconURL` is absent too. HTTPS URL or `data:image/png;base64,...`, 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. | | `backgroundURL` | string | Pro | No | Full-bleed poster artwork. **Setting this adds the `posterGeneric` style** (iOS 27 and later): logo and `logoText`, one header field, up to 4 primary fields, up to 2 footer fields, and the barcode over the image. Poster text is always white with light labels, since it sits on the darkened artwork; `color` and `colorPreset` still set the background behind it and shape the classic fallback. Older iOS keeps the classic layout. Google Wallet uses it as the hero image when there is no `stripURL`. HTTPS URL or PNG data URI. Apple sizes the poster at 345×505 pt, so send 690×1010 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. | | `credentials` | string | Pro | No | Slug of one of your credential sets, so the pass is signed with your own Apple certificate and issued from your own Google issuer. Omit it, or send `platform`, for the WalletWallet certificates. Fixed at creation: a PUT may repeat the value but cannot change it. See "Your own certificates". | | `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://pass.walletwallet.dev/p/...", "credentials": "platform" } ``` 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 Live updates are a Pro feature. 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. `expirationDays` counts from the request that sets it, so a PUT that sends `expirationDays: 365` moves the expiry to one year after the update. To keep an exact expiry across updates, send `expirationDate` instead. A past `expirationDate` is accepted on PUT and files the pass as expired on both wallets without revoking it; a later PUT with a future date brings it back. ### 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). When the pass expires, the response also carries `expirationDate`, the absolute UTC instant it resolved to, on the changed and the unchanged path alike. ### 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://pass.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 text and the wallet buttons come in English, French, Spanish, German, Italian, and Slovak. The page picks the language from the visitor's browser (`Accept-Language`) and falls back to English. A language menu on the page lets the visitor change it. To pin a language, add `?lang=` with `en`, `fr`, `es`, `de`, `it`, or `sk` to the link, for example `https://pass.walletwallet.dev/p/?lang=fr`. The pass title and the organization name appear as sent in the pass request. By default the page takes its look from the pass itself (logo and color), so it looks like your pass rather than 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. Custom domain (Business): the page can live on a subdomain the team owns, such as `https://passes.example.com/p/`. The owner adds the host under Dashboard > Custom domain and creates a CNAME from it to `passes.walletwallet.dev` (set to DNS only if the DNS is on Cloudflare). WalletWallet issues the certificate once the record resolves. One subdomain per team; an apex domain cannot be added. While the domain is active, `shareUrl`, the `X-Pass-Url` header, the CSV export, and the dashboard share links and QR codes use it. The path is the same on both hosts, so `https://pass.walletwallet.dev/p/` keeps working. Removing the domain, or moving to a plan without the feature, returns every link to `pass.walletwallet.dev`. Branding (Business): the page carries the team's own look on `pass.walletwallet.dev` and on the custom domain. The owner sets it under Dashboard > Branding: page background, card color, card text color, a logo or brand name above the card, the headline, a footer line with a link, and a favicon, with a live preview. Every field is optional; an unset field keeps the standard look, so a card with no color of its own keeps the color of each pass. The settings apply to live and revoked passes. Moving to a plan without the feature pauses the branding: visitors see the standard page and the settings stay until the plan comes back. ## 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`, `business`, or `enterprise` (the Scale plan). 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`. `limit` is the volume your plan includes and `remaining` counts down against it. On `business` and `enterprise` the included limit is soft: requests keep working past it and we reach out to discuss your volume. `resetDate` is the first of the next month (UTC). ## Your own certificates (credential sets) By default WalletWallet signs Apple passes with its own Pass Type ID and mints Google passes from its own issuer. On Pro and above an account can add any number of **credential sets**: a slug plus your own Apple Pass Type ID certificate, an optional APNs key for pushes, and an optional Google Wallet issuer. Name a set in the `credentials` field of `POST /api/passes` and the pass is issued under your identity end to end. Manage sets in the dashboard under Certificates or over the API. - The slug is chosen at creation: lowercase `a-z0-9-`, 2 to 40 characters, unique per account, immutable. `platform` is reserved for the WalletWallet certificates. - `credentials` is fixed when the pass is created. A PUT may omit it or repeat the same value; a different value is a 400. - A set with only an Apple certificate issues no `googleSaveUrl`. A set with only a Google issuer issues no `applePass`, and the binary `POST /api/pkpass` answers 400 for it. A set with neither unit is a 400. An unknown slug is a 400; naming a set on Free is a 403. - The create response, `GET /api/passes/`, the rows of `GET /api/passes`, and the CSV from `GET /api/passes/export` carry `credentials`. `GET /api/passes?credentials=` and `GET /api/passes/export?credentials=` filter to one set; `credentials=platform` filters to the WalletWallet-signed passes. Endpoints, all under Bearer auth, 403 on Free, secret material never echoed: | Endpoint | Body | What it does | |---|---|---| | `GET /api/credentials` | | `{ sets: [...] }`. Each set: `slug`, `displayName`, `livePasses`, `apple` (`configured`, `pendingCsr`, then `passTypeIdentifier`, `teamIdentifier`, `organizationName`, `certExpiresAt`, `apnsConfigured`, `apnsKeyId` once configured), `google` (`configured`, then `issuerId`, `serviceAccountEmail`, `classSuffix`), `createdAt`, `updatedAt` | | `POST /api/credentials` | `{ slug, displayName? }` | Creates a set, answers 201 with it | | `GET /api/credentials/` | | One set, same shape | | `PATCH /api/credentials/` | `{ displayName }` | Renames the set; the slug never changes | | `DELETE /api/credentials/` | | Deletes the set, answers `{ deleted, slug, livePasses }` | | `POST /api/credentials//apple/csr` | | Generates a key pair server-side and returns `{ csr, filename }` to upload to the Apple Developer portal. `GET` on the same path re-downloads the pending CSR | | `POST /api/credentials//apple/certificate` | `{ certificate }` | The Apple-issued `.cer`, PEM or base64 DER. Must match the pending CSR; the pass type id, team id, and organization name are read from it | | `PUT /api/credentials//apple/apns` | `{ keyId, p8Pem }` | APNs auth key for pushes. `DELETE` removes it | | `DELETE /api/credentials//apple` | | Removes the certificate, the pending CSR, and the APNs key | | `PUT /api/credentials//google` | `{ issuerId, serviceAccountJson, classSuffix? }` | Google Wallet issuer and its service account key JSON. `DELETE` removes it | Rules: - Deleting a set, removing its Apple certificate, uploading a certificate for a different pass type, or changing or removing its Google issuer answers `409` with `livePasses` while live passes use it. Revoke them first or add `?force=true`; after a forced change those passes stop serving (`410 Gone`, which makes Apple Wallet unregister them) and their Google objects stop updating. - An Apple pass type id belongs to one set across all accounts. A Google issuer belongs to one account but may appear in several of its sets. Claiming a taken one answers `409`, with `conflictingSet` when the holder is one of your own sets. - An Apple certificate with no APNs key signs passes correctly but can never push an update, so installed passes go stale with no error. Add the key. - A pass keeps its set for life. After a downgrade it still serves, updates, and pushes; only new creates that name a set answer `403`. ## 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 1024 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`, `expirationDate must be an RFC 3339 date-time with a UTC offset, such as 2027-01-31T23:59:00Z or 2027-01-31T23:59:00+02:00`, `expirationDate and expirationDays cannot both be set`, `expirationDate must be in the future`, `expirationDate must be no more than 10 years ahead`, `locations[0].latitude must be between -90 and 90`, `URL must use HTTPS protocol`, `featuredActions must have 2 or fewer items`, `featuredActions[0].type must be one of: viewSchedule, ...`, `featuredActions[0].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. - `wideLogoURL` → Apple: logo top-left only when `logoURL` is absent. Google: wide logo top-left; `logoText` and the round logo are dropped from the card. - `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. - `backgroundURL` → Apple: full-bleed poster face on iOS 27+ (`posterGeneric`), classic layout below that. The bundle also carries the logo as `primaryLogo.png` (from `wideLogoURL`, else `logoURL`). Google: the hero image at the bottom of the card, unless `stripURL` takes that slot. - `footerFields` → Apple: up to two fields under the primary fields on the poster face (iOS 27+ only). Google: pass details view. - `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. - `featuredActions` → Apple: up to two tiles under the pass face (iOS 27+), icon and label chosen by Apple from `type`. Google: `linksModuleData` buttons, listed before any link-shaped back fields, labeled from the type (`membershipBenefits` reads "Membership benefits"); a `place` action links to its optional `url`, else to its Place ID on maps.apple.com. - `expirationDays` / `expirationDate` → Apple: `expirationDate` in `pass.json`; Wallet files the pass under Expired once it passes. Google: `validTimeInterval.end`; Google Wallet shows the pass as expired after it. - `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` | | `backgroundURL` provided | `posterGeneric` next to the key above, which iOS 26 and earlier fall back to | `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/ - Integrations (ChatGPT, Claude, Zapier; MCP server at https://api.walletwallet.dev/mcp): https://walletwallet.dev/integrations/ - Signup / get an API key: https://walletwallet.dev/signup/ ## Use-case guides (payload examples per use case) - Loyalty cards (points, cashback, tiers): https://walletwallet.dev/use-cases/digital-loyalty-cards/ - Punch / stamp cards (buy N get one): https://walletwallet.dev/use-cases/digital-punch-cards/ - Membership cards (gyms, clubs, museums): https://walletwallet.dev/use-cases/digital-membership-cards/ - Event tickets (batch issue, update, revoke): https://walletwallet.dev/use-cases/digital-event-tickets/ - Contact: alen@walletwallet.dev