WalletWallet API

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

Editing pass loading...
View all passes

Text next to the logo (top-left)

Notification title on pass updates. Defaults to your account name.

No header fields

The poster layout shows the first header field only.

No primary fields

Without a background image Apple shows only the first primary field on the pass face; Google shows the first as the title and lists the rest in the pass details.

The poster layout shows up to four primary fields on the pass face; Google shows the first as the title and lists the rest in the pass details.

No secondary fields

The poster layout keeps secondary fields off the pass face on iOS 27; older devices and Google still show them.

Up to two short lines along the bottom of the poster layout.

Apple shows the value only; the label is saved but not drawn.

Without a background image Apple keeps them off the pass face; Google still lists them in the pass details.

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.

Up to two buttons under the pass. Apple sets the icon and wording; Google Wallet shows them as links.

Pro feature. Upgrade to add up to two buttons under the pass.

Line breaks are kept, so a multi-line value such as a vCard scans as one. Up to 1024 characters.

By default Google prints the value under the code and Apple prints nothing.

Overrides color preset

EXPIRES

Google Wallet fills the screen with the pass: your background image becomes a full-width band under the fields, and featured actions become tappable rows.

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.

The key belongs to your team, not to a person. Every team member reads the same key on the dashboard, and every pass it creates is the team's. Any member can rotate it, which revokes the old key at once. Rotate it when someone leaves the team.

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

plan is one of free, trial, pro, business, or enterprise (the Scale plan). A new account starts on trial, which carries every feature at the free volume for 7 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.

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 No Optional. Data encoded in the barcode. Max 1024 characters. Omit it for a pass with no barcode.
barcodeFormat string No Required only when you send a barcodeValue. One of QR PDF417 Aztec Code128. Code128 is a 1D barcode whose width grows with every character — keep its value under ~80 characters, or it renders too wide to scan reliably. The 2D formats (QR, Aztec, PDF417) handle the full 1024 characters.
barcodeAltText string No Text under the barcode. Max 128 characters. 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 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. label is optional: omit it (or send an empty string) and the value renders alone with no label on both wallets, which makes it display larger. 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.
footerFields Pro array No Up to 2 fields along the bottom of the poster layout. Apple draws them on iOS 27 and later with backgroundURL; Google lists them in the pass details either way. Array of {label?, value, changeMessage?} objects; Apple draws the value only and ignores the label. Google Wallet lists them in the pass details.
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.
featuredActions Pro array No Up to 2 tappable tiles under the pass face on iOS 27 and later. Array of {identifier, type, url} objects in priority order. Apple draws the icon and label from type; one of Apple's types: viewSchedule, watchTrailer, listenToMusic, call, place, addToBalance, order, shop, membershipBenefits, bookAppointment, bookCar, bookFlight, bookStay, viewOffersRewards. Other values are refused because one unknown type hides every tile on the pass. url must be public HTTPS, or a tel: number for call. Google Wallet shows each action as a link button. Older iOS ignores the key.
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. Recommended 160×160 px, max 1MB.
wideLogoURL Pro string 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 PNG data URI, max 1MB.
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 when omitted; send an empty string for a label-less title.
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. Recommended 180×180 px, max 1MB.
stripURL Pro string No Wide banner image behind the primary field. Switches pass to store card layout. HTTPS URL or PNG data URI. Recommended 1080×360 px, max 1MB.
backgroundURL Pro string No Full-bleed poster artwork. On iOS 27 and later the pass uses Apple's poster layout: logo and logoText, one header field, up to four primary fields, up to two footer fields, and the barcode over the image, in white text with light labels. Older iOS keeps the classic layout. Google Wallet shows it beneath the card 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 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. Recommended 120×120 px, max 1MB.
credentials Pro string No The 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.

At least one of logoText, primaryFields, or title must be provided.

Size limits: each image is max 1MB, the request body max 2MB, and the built .pkpass max 10MB. Exceeding any of these returns 400.

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://pass.walletwallet.dev/p/8f4c3a2e-...",
  "credentials": "platform"
}

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"
  }'
DELETE

/api/passes/<serial>

Revokes a pass you issued: it is invalidated on every device it is installed on, on both wallets. Requires authentication and ownership of the serial. The legacy alias DELETE /api/pkpass/<serial> works the same way.

How revoke works on each wallet

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.

Headers

Header Value
Authorization Bearer ww_live_<your_key>

No request body is needed; any body is ignored. The serial in the URL path identifies the pass.

Response

200

Pass revoked. The empty Apple push runs in the background; the void lands on the device's next poll.

{
  "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 (omitted when Google is not configured). A repeat call is a no-op success:

{
  "serialNumber": "8f4c3a2e-...",
  "deleted": true,
  "alreadyDeleted": true,
  "notifiedDevices": 0
}
401 Invalid or missing API key
404 Unknown serial, or a serial owned by a different key (revoke never reveals another account's serial)
429 Monthly rate limit exceeded
cURL, revoke a pass
curl -X DELETE https://api.walletwallet.dev/api/passes/<serial> \
  -H "Authorization: Bearer ww_live_<your_key>"

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://pass.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 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/<serial>?lang=fr. The pass title and your organization name appear as you sent them.

By default the page takes its look 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.

Custom domain

On the Business plan the page can live on a subdomain you own, such as https://passes.example.com/p/<serial>. Add the host on Dashboard › Custom domain and create a CNAME record from it to passes.walletwallet.dev at your DNS provider. If your DNS is on Cloudflare, set the record to DNS only. We issue the certificate once the record resolves, which usually takes a few minutes. An apex domain cannot be added.

While the domain is active, shareUrl, the X-Pass-Url header, the CSV export, and the share links and QR codes in the dashboard all use it. The path is the same on both hosts, so a pass is reachable on pass.walletwallet.dev too. One domain per team. Removing the domain, or moving to a plan without the feature, returns every link to pass.walletwallet.dev, and links on the custom host stop working.

Branding

On the Business plan the page carries your own look, on pass.walletwallet.dev and on your custom domain. Dashboard › Branding sets the page background, the card color, the card text color, a logo or brand name above the card, the headline, a footer line with a link, and a favicon. A live preview shows the page before you save. 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, and the pass logo stays on the card unless you hide it.

The settings apply to every share page of the team, live or revoked. The owner manages them. Moving to a plan without the feature pauses the branding: visitors see the standard page, and the settings stay until the plan comes back.

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.

Your own certificates

By default every pass is signed with the WalletWallet Apple certificate and issued from the WalletWallet Google issuer. On Pro and above you can add a credential set instead: your own Apple Pass Type ID certificate, an optional APNs key for updates, and an optional Google Wallet issuer. A pass created with a set carries your identity end to end, and your account can hold as many sets as it needs, one per brand or per client.

Each set has a slug you choose at creation: lowercase letters, digits, and hyphens, 2 to 40 characters, unique in your account, and fixed once created. platform is reserved for the WalletWallet certificates. Name the set in the credentials field of POST /api/passes. The create response, the pass status, the pass list, and the CSV export echo the slug back, and the list and the export accept ?credentials=<slug> to filter to one set.

A set with only an Apple certificate issues Apple passes and no googleSaveUrl. A set with only a Google issuer issues Google passes and no applePass, so the binary POST /api/pkpass answers 400 for it. A set with neither is refused. Your Apple private key never leaves our servers: we generate the key pair, you download the certificate signing request, upload it to the Apple Developer portal, and send the issued .cer back. Manage sets in Dashboard › Certificates or over the API below.

Endpoints

All endpoints take your API key or a dashboard session and answer 403 on Free. Secret material is stored encrypted and never echoed back.

Endpoint Body Description
GET /api/credentials none Lists your sets as { sets }. Each carries slug, displayName, livePasses, and the state of its apple and google units.
POST /api/credentials { slug, displayName? } Creates a set. Answers 201.
GET /api/credentials/<slug> none One set, same shape as a list entry.
PATCH /api/credentials/<slug> { displayName } Renames the set. The slug never changes.
DELETE /api/credentials/<slug> none Deletes the set. Answers { deleted, slug, livePasses }.
POST /api/credentials/<slug>/apple/csr none Generates a key pair on our side and returns { csr, filename } to upload to the Apple Developer portal. GET on the same path re-downloads the pending request.
POST /api/credentials/<slug>/apple/certificate { certificate } The Apple-issued certificate, PEM or base64 DER. It must match the pending request. The pass type id, team id, and organization name are read from it.
PUT /api/credentials/<slug>/apple/apns { keyId, p8Pem } The APNs auth key that pushes updates to installed passes. DELETE removes it.
DELETE /api/credentials/<slug>/apple none Removes the certificate, the pending request, and the APNs key.
PUT /api/credentials/<slug>/google { issuerId, serviceAccountJson, classSuffix? } Your Google Wallet issuer and its service account key. 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 the number of live passes it would strand. Revoke them first, or add ?force=true. After a forced change those passes stop serving with 410, 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 one that is taken answers 409, naming the set when it is yours.
  • 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 to Free it still serves, updates, and pushes; only new creates that name a set answer 403.

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 $39/mo
Business 1,000,000 Yes Yes $99/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.

On Business the 1,000,000 limit is soft: requests keep working past it and we reach out to discuss your volume.

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
barcodeValue too long barcodeValue must be 1024 characters or less
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.

Start free

Free plan includes 1,000 passes/month