WalletWallet API
Get API Key Docs Pricing Changelog Log in

API Documentation

Complete reference for the WalletWallet API. Issue passes to Apple Wallet and Google Wallet with a single HTTP request.

Base URL: 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.

EXPIRES

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:

Header
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.

GET

/api/auth/usage

Returns your current monthly usage statistics. Requires authentication.

Headers

Header Value
Authorization Bearer ww_live_<your_key>

Response

200
{
  "count": 150,
  "limit": 1000,
  "remaining": 850,
  "resetDate": "2026-03-01",
  "plan": "free"
}
401 Invalid or missing API key
cURL
curl https://api.walletwallet.dev/api/auth/usage \
  -H "Authorization: Bearer ww_live_<your_key>"
POST

/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

200

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.

400 Validation error — missing or invalid fields
401 Invalid or missing API key
429 Monthly rate limit exceeded
cURL — minimal
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 — all options
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 — Pro features (custom color + logo)
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.
PUT

/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 include serialNumber in the body.
  • authenticationToken is server-owned and immutable once issued; including it in the body returns 400.
  • To surface a custom lock-screen banner on update, add changeMessage on 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

200

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
}
400 Validation error, or body includes a server-owned field (serialNumber / authenticationToken)
401 Invalid or missing API key
403 Serial belongs to a different API key
404 Unknown serial
429 Monthly rate limit exceeded (changed-body PUTs count; unchanged PUTs do not)
cURL — update with a changeMessage banner
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"
  }'

Share a pass

Every pass you create comes with a hosted install page. The create response returns its URL as shareUrl (and on the legacy binary endpoint, as the X-Pass-Url header). It looks like https://api.walletwallet.dev/p/<serial>.

Send that one link however you already reach people — 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:

  • On iPhone — an Add to Apple Wallet button installs the signed pass directly.
  • On Android — a Save to Google Wallet button adds the same pass to Google Wallet.
  • On 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 feels like your pass rather than a generic install screen. There's no app to install and nothing to host on your side. The link stays live for the life of the pass, and the same serial is what you use to update it later.

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 — create with the anchor seeded
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 — send a custom banner
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 backFields so it does not crowd the pass face. Customers only see "Notifications: <last message>" 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.
  • 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.

dark (default)
blue
green
red
purple
orange

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

JavaScript / Node.js
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'));
Python
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']))
Ruby
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']))
PHP
$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']));
}
Go
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.

Get Your API Key

Free plan includes 1,000 passes/month