llms.txt
# 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/<serial>` 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/<serial>` 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, `notification`, `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_<your_key>`
- Content type for write endpoints: `application/json`
- Errors are JSON: `{"error": "<message>"}`. 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="<slug>.pkpass"` (slug derived from `title` or `logoText`, lowercased, non-alphanumerics replaced with `-`)
- `X-Serial-Number: <uuid>`, 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/<jwt>`, 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. |
| `notification` | string | All | No | The lock-screen message. Apple shows the new value as the banner on every update that changes it; Google lists it in the pass details and sends its own notification. Max 256 chars. Create the pass with a blank value (a single space) so the first message banners. See "Send a notification". |
| `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=<placeIdentifier>`. 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_<your_key>" \
-d '{
"barcodeValue": "MEMBER-12345",
"barcodeFormat": "QR",
"logoText": "Membership Card"
}'
# Returns JSON: { "serialNumber": "...", "googleSaveUrl": "https://pay.google.com/gp/v/save/...", "applePass": "<base64 .pkpass>", "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/<serial>, update an issued pass
Live updates are a Pro feature. Replaces the stored body for `<serial>`. 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_<your_key>" \
-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/<serial>, 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/<serial>` 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_<your_key>"
```
## 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/<serial>`.
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