API Documentation
Complete reference for the WalletWallet API. Issue passes to Apple Wallet and Google Wallet with a single HTTP request.
https://api.walletwallet.dev What each field does
Edit any field and see the pass update live
Text next to the logo (top-left)
Notification title on pass updates. Defaults to your account name.
No header fields
No primary fields
No secondary fields
No back fields
Click the pin to fill a row with where you are, for testing. The real pass needs the actual place.
Wallet shows the pass on the lock screen within ~100m of a coordinate.
Pro feature — upgrade to add up to 10 lock-screen location triggers per pass.
Overrides color preset
Lock-screen and pass-update notification image. Defaults to your logo.
Wide banner image. Use secondary fields for readable text when using this option.
On Google, your strip image shows beneath the QR code, and header fields sit in the field row, not the top-right corner.
Overview
The WalletWallet API lets you issue passes to both Apple Wallet and Google Wallet programmatically. Send a POST request with your pass data and receive a signed Apple .pkpass file plus a Save to Google Wallet link for the same pass. A single PUT later updates and pushes to both wallets.
All requests use JSON bodies and return JSON responses. POST /api/passes is the canonical create endpoint and returns a JSON envelope { serialNumber, googleSaveUrl, applePass } (applePass is the base64 .pkpass). The legacy POST /api/pkpass hits the same handler but streams the raw binary .pkpass instead, with the serial and Google link on response headers. See the legacy note below.
Authentication
All API requests require a valid API key. Include it in the Authorization header using the Bearer scheme:
Authorization: Bearer ww_live_<your_key>
API keys follow the format ww_live_ followed by 32 hexadecimal characters. You can get one instantly from the signup page.
/api/auth/usage
Returns your current monthly usage statistics. Requires authentication.
Headers
| Header | Value |
|---|---|
| Authorization | Bearer ww_live_<your_key> |
Response
{
"count": 150,
"limit": 1000,
"remaining": 850,
"resetDate": "2026-03-01",
"plan": "free"
} curl https://api.walletwallet.dev/api/auth/usage \
-H "Authorization: Bearer ww_live_<your_key>" /api/passes
Issues a pass to both wallets in one call. Returns JSON { serialNumber, googleSaveUrl, applePass, shareUrl }, where applePass is the base64-encoded signed .pkpass, googleSaveUrl is the Save to Google Wallet link, and shareUrl is a hosted install page for the same pass. This is the canonical endpoint. Requires authentication.
Headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| Authorization | Bearer ww_live_<your_key> |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| barcodeValue | string | Yes | Data encoded in the barcode. Max 512 characters. |
| barcodeFormat | string | Yes | QR PDF417 Aztec Code128 |
| logoText | string | No | Text next to the logo (top-left of pass). |
| description | string | No | Accessibility text (not visible). Defaults to logoText. |
| organizationName | string | No | Issuer name shown as the lock-screen notification title on pass updates, on the lock-screen relevance surface near a configured location, in the iOS share sheet, and in the Wallet pass info screen. Max 64 characters. Falls back to the account default when omitted. |
| primaryFields | array | No | Main content fields. Array of {label, value, changeMessage?} objects. changeMessage is the lock-screen banner template fired when this field's value changes — see Update Pass. |
| secondaryFields | array | No | Fields below primary. Array of {label, value, changeMessage?} objects. |
| headerFields | array | No | Top-right header area. Array of {label, value, changeMessage?} objects. |
| backFields | array | No | Back of pass. Array of {label, value, changeMessage?} objects. |
| locations | array | No | Up to 10 geofences that surface the pass on the lock screen when the device is nearby. Array of {latitude, longitude, altitude?, relevantText?} objects. Latitude is -90…90, longitude -180…180, relevantText ≤ 128 chars. |
| sharingProhibited | boolean | No | Hides the Apple Wallet share button. Defaults to true, which keeps passes private (best for loyalty and membership cards). Set false to let holders share the pass. |
| colorPreset | string | No |
Color theme:
dark blue green red purple orange.
Defaults to dark.
|
| expirationDays | number | No |
Pass expires after this many days. Common presets:
30,
90,
365.
Any integer between
1 and
3650
is accepted.
|
| color Pro | string | No | Custom hex background color, e.g. #1e40af. Overrides colorPreset. |
| logoURL Pro | string | No | Custom logo image. Must use HTTPS — HTTP URLs are rejected. Also accepts PNG data URIs (data:image/png;base64,...). Private/internal addresses are not allowed. |
| title Legacy | string | No | Legacy shortcut. Sets primaryFields[0].value and logoText if those aren't set. |
| cardLabel Legacy | string | No | Legacy shortcut. Sets primaryFields[0].label. Defaults to CARD. |
| label Legacy | string | No | Legacy shortcut. Sets secondaryFields[0].label. |
| value Legacy | string | No | Legacy shortcut. Sets secondaryFields[0].value. |
| thumbnailURL Pro | string | No | Image shown top-right of the pass. HTTPS URL or PNG data URI. |
| stripURL Pro | string | No | Wide banner image behind the primary field. Switches pass to store card layout. HTTPS URL or PNG data URI. |
| iconURL Pro | string | No | Replaces the default icon.png shown in iOS lock-screen notifications. Distinct from logoURL, which renders on the pass face. HTTPS URL or PNG data URI. |
At least one of logoText, primaryFields, or title must be provided.
Response
Returns application/json. Decode applePass from base64 to get the signed .pkpass bytes, or open googleSaveUrl on Android to install the Google pass. The simplest path: send users shareUrl — a hosted page that shows the right Add to Wallet button per device, with a QR on desktop.
{
"serialNumber": "8f4c3a2e-...",
"googleSaveUrl": "https://pay.google.com/gp/v/save/<jwt>",
"applePass": "UEsDBBQAAAAI...(base64 .pkpass)",
"shareUrl": "https://api.walletwallet.dev/p/8f4c3a2e-..."
} Save serialNumber if you plan to send updates — you'll pass it to PUT /api/passes/<serial>. The same value is also baked into pass.json. For a public Add to Google Wallet button, the 302 redirect at GET /api/passes/<serial>/google resolves to the same link.
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"
}' curl -X POST https://api.walletwallet.dev/api/passes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ww_live_<your_key>" \
-d '{
"barcodeValue": "LOYALTY-98765",
"barcodeFormat": "QR",
"logoText": "Bayroast Coffee",
"description": "Loyalty card for Bayroast Coffee",
"primaryFields": [{"label": "CARD", "value": "Coffee Rewards"}],
"secondaryFields": [{"label": "TIER", "value": "Gold Status"}],
"headerFields": [{"label": "BALANCE", "value": "$25.00"}],
"colorPreset": "green",
"expirationDays": 365
}' curl -X POST https://api.walletwallet.dev/api/passes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ww_live_<your_key>" \
-d '{
"barcodeValue": "VIP-001",
"barcodeFormat": "QR",
"logoText": "VIP Access",
"primaryFields": [{"label": "PASS", "value": "VIP Access"}],
"color": "#8B4513",
"logoURL": "https://example.com/logo.png"
}' Legacy: POST /api/pkpass (binary)
Same handler, different default response shape. POST /api/pkpass streams the raw application/vnd.apple.pkpass binary instead of JSON. The serial and Google link ride on response headers (below) rather than the body. Add ?format=json to get the JSON envelope above, or just call /api/passes. Conversely, POST /api/passes?format=pkpass streams the binary.
| Response header | Description |
|---|---|
| Content-Type | application/vnd.apple.pkpass |
| Content-Disposition | Suggested filename, e.g. attachment; filename="card.pkpass" |
| X-Serial-Number | Server-generated serial for the new pass — the same value returned as serialNumber in the JSON envelope. CORS-exposed. |
| X-Google-Save-Url | Save to Google Wallet link (https://pay.google.com/gp/v/save/<jwt>) for the same pass — the same value returned as googleSaveUrl. CORS-exposed. |
| X-Pass-Url | Hosted install page for the pass — the same value returned as shareUrl. CORS-exposed. |
/api/passes/<serial>
Updates a previously-issued pass. Devices that have it installed receive an Apple Push Notification within seconds; Wallet refreshes the pass in place with a lock-screen banner. Requires authentication and ownership of the serial.
How updates reach the device
The serial number you got back from POST /api/passes (the serialNumber field, also baked into pass.json) is your update handle. PUT a new body with that serial and the pass updates on both wallets: every Apple device gets an APNs push and pulls the new content, and the Google pass is updated and pushed at the same time. The Apple lock-screen banner text comes from any field's changeMessage (without one, iOS shows the default "Pass Updated"); Google's update banner is generic and the changed content shows inside the pass.
Headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| Authorization | Bearer ww_live_<your_key> |
Request Body
Same shape as POST /api/passes: send the full pass spec. The server replaces the stored body, recomputes a content hash, and fans out push notifications to registered devices.
- The URL path's
<serial>identifies the pass — do not includeserialNumberin the body. authenticationTokenis server-owned and immutable once issued; including it in the body returns 400.- To surface a custom lock-screen banner on update, add
changeMessageon the field whose value is changing (e.g."You earned %@ points"). iOS substitutes%@with the new value. - An identical body returns
{ unchanged: true }with no push and no quota impact — safe to retry.
Response
Body change accepted. APNs fan-out runs in the background.
{
"serialNumber": "8f4c3a2e-...",
"lastUpdated": 1778538208273,
"notifiedDevices": 3,
"unchanged": false
} If the body is byte-equivalent to the stored one, no push fires and no usage counts:
{
"serialNumber": "8f4c3a2e-...",
"lastUpdated": 1778538208273,
"notifiedDevices": 0,
"unchanged": true
} serialNumber / authenticationToken) curl -X PUT https://api.walletwallet.dev/api/passes/<serial> \
-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": "250",
"changeMessage": "You now have %@ points"
}
],
"colorPreset": "green"
}' Send a notification
To push a custom lock-screen notification on demand, ship a backFields "notification anchor". Put the message text in value, and set changeMessage to literally "%@". The wallet substitutes %@ with the new value at render time, so the banner reads whatever you wrote.
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.
1. Seed the anchor on pass creation
The anchor must exist on the pass before any update can target it. If it does not, the first send is silently suppressed by rule 1.
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": "Loyalty Card",
"backFields": [
{ "label": "Notifications", "value": " ", "changeMessage": "%@" }
]
}' 2. Send a message
Bump the anchor's value to the message text. Keep changeMessage as "%@".
curl -X PUT https://api.walletwallet.dev/api/passes/<serial> \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ww_live_<your_key>" \
-d '{
"barcodeValue": "MEMBER-12345",
"barcodeFormat": "QR",
"logoText": "Loyalty Card",
"backFields": [
{ "label": "Notifications", "value": "Free coffee on us!", "changeMessage": "%@" }
]
}' Practical notes
- The anchor lives in
backFieldsso it does not crowd the pass face. Customers only see "Notifications: <last message>" if they tap ⓘ to flip the pass. - Keep the
backFieldsarray order stable between POST and every PUT. Fields are identified by position; reordering the array re-keys the anchor and the banner stops firing. - 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 twice in a row is a no-op. The value did not change, so no banner fires. Vary the text if you need a repeat.
Barcode Formats
| Format | Type | Best For |
|---|---|---|
| QR | 2D square | General purpose, high data capacity, most common |
| PDF417 | 2D stacked | Boarding passes, ID cards, government documents |
| Aztec | 2D square | Transit tickets, compact spaces, no quiet zone needed |
| Code128 | 1D linear | Retail, inventory, shipping labels |
Color Presets
Available on all plans. Use the colorPreset field.
Pro plan: Use the color field with any hex value (e.g. #1e40af) to set a fully custom background color.
Rate Limits
| Plan | Passes / Month | Custom Color | Custom Logo | Price |
|---|---|---|---|---|
| Free | 1,000 | No | No | $0 |
| Pro | 100,000 | Yes | Yes | $19/mo |
Usage resets on the 1st of each month (UTC). You can check your current usage at any time via the /api/auth/usage endpoint.
POST always counts. PUT only counts when the body actually changes — an unchanged PUT is free, no push fires, no quota moves. Devices polling the Wallet web service do not count either.
When you exceed your limit, the API returns a 429 response with the reset date:
{
"error": "Rate limit exceeded",
"resetDate": "2026-03-01",
"message": "Monthly limit reached. Resets on 2026-03-01"
} Errors
All error responses return JSON with an error field:
{
"error": "Error message describing the issue"
} HTTP Status Codes
| Code | Description |
|---|---|
| 200 | Success |
| 400 | Bad request — invalid input, malformed JSON, or validation failure |
| 401 | Unauthorized — missing or invalid API key |
| 404 | Not found — endpoint does not exist |
| 405 | Method not allowed — wrong HTTP method |
| 429 | Rate limit exceeded — monthly quota used up |
| 500 | Internal server error |
Common Validation Errors
| Cause | Error Message |
|---|---|
| Missing required field | barcodeValue is required |
| Invalid barcode format | barcodeFormat must be one of: QR, PDF417, Aztec, Code128 |
| Title too long | title must be 64 characters or less |
| Custom color on free plan | color is only available on the Pro plan |
| Invalid expiration | expirationDays must be between 1 and 3650 |
Code Examples
const response = await fetch('https://api.walletwallet.dev/api/passes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ww_live_<your_key>'
},
body: JSON.stringify({
barcodeValue: 'TICKET-789',
barcodeFormat: 'QR',
logoText: 'Event Ticket',
primaryFields: [{ label: 'EVENT', value: 'Concert' }],
secondaryFields: [{ label: 'Seat', value: 'A-23' }]
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
// One JSON response covers both wallets
const { serialNumber, googleSaveUrl, applePass } = await response.json();
// googleSaveUrl -> "Add to Google Wallet" link. applePass -> base64 .pkpass.
// Browser: trigger the Apple Wallet download
const bytes = Uint8Array.from(atob(applePass), c => c.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/vnd.apple.pkpass' }));
const a = document.createElement('a');
a.href = url;
a.download = 'ticket.pkpass';
a.click();
// Node.js: save to file
// const fs = require('fs');
// fs.writeFileSync('ticket.pkpass', Buffer.from(applePass, 'base64')); import requests, base64
response = requests.post(
'https://api.walletwallet.dev/api/passes',
headers={
'Content-Type': 'application/json',
'Authorization': 'Bearer ww_live_<your_key>'
},
json={
'barcodeValue': 'ORDER-456',
'barcodeFormat': 'Code128',
'logoText': 'Order Pickup',
'primaryFields': [{'label': 'ORDER', 'value': 'Pickup'}],
'secondaryFields': [{'label': 'Order #', 'value': '456'}]
}
)
response.raise_for_status()
data = response.json()
# data['serialNumber'], data['googleSaveUrl'] (Add to Google Wallet link)
with open('order.pkpass', 'wb') as f:
f.write(base64.b64decode(data['applePass'])) require 'net/http'
require 'json'
require 'uri'
require 'base64'
uri = URI('https://api.walletwallet.dev/api/passes')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request['Authorization'] = 'Bearer ww_live_<your_key>'
request.body = {
barcodeValue: 'MEMBER-001',
barcodeFormat: 'QR',
logoText: 'Gym Membership',
primaryFields: [{ label: 'MEMBER', value: 'Premium' }]
}.to_json
response = http.request(request)
data = JSON.parse(response.body)
# data['serialNumber'], data['googleSaveUrl']
File.binwrite('membership.pkpass', Base64.decode64(data['applePass'])) $ch = curl_init('https://api.walletwallet.dev/api/passes');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ww_live_<your_key>'
],
CURLOPT_POSTFIELDS => json_encode([
'barcodeValue' => 'COUPON-50OFF',
'barcodeFormat' => 'QR',
'logoText' => 'Discount Coupon',
'primaryFields' => [['label' => 'COUPON', 'value' => '50% Off']]
])
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$data = json_decode($response, true);
// $data['serialNumber'], $data['googleSaveUrl']
file_put_contents('coupon.pkpass', base64_decode($data['applePass']));
} package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"os"
)
func main() {
body, _ := json.Marshal(map[string]interface{}{
"barcodeValue": "PASS-999",
"barcodeFormat": "QR",
"logoText": "Access Pass",
})
req, _ := http.NewRequest("POST", "https://api.walletwallet.dev/api/passes", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer ww_live_<your_key>")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var data map[string]string
json.NewDecoder(resp.Body).Decode(&data)
// data["serialNumber"], data["googleSaveUrl"]
pkpass, _ := base64.StdEncoding.DecodeString(data["applePass"])
os.WriteFile("access.pkpass", pkpass, 0644)
} Testing Your Pass
The fastest way to test is right inside the Pass Editor. Design your pass, click Generate, and scan the QR to add it on your phone, Apple Wallet on iPhone or Google Wallet on Android, within seconds.
Once installed, you can also send yourself live updates (any field change) and custom lock-screen banner notifications from the same view — useful for verifying your changeMessage templates before going to production.
Tip: The QR-and-install flow works cross-device — design on your Mac, install and test on your phone.
Free plan includes 1,000 passes/month