wallet-mcp: free Apple Wallet pass generator for Claude, ChatGPT and any MCP client
Turn a PDF boarding pass, ticket, coupon or loyalty card into a signed Apple Wallet pass (.pkpass) from an AI assistant, with no Apple Developer account. wallet-mcp is the open-source server behind walletmcppass.com: an MCP server with two tools, create_wallet_pass and update_wallet_pass, and the same pass builder as a REST API.
Quick start
Claude (web, desktop and mobile; any plan, Free allows one custom connector): open Customize → Connectors, click + Add → Add custom connector, name it walletmcppass, paste https://walletmcppass.com/mcp, choose No sign in and click Add. In a chat, click + → Connectors and switch it on. Full steps.
ChatGPT: go to chatgpt.com/plugins, select + → Add custom MCP server, name it walletmcppass, enter https://walletmcppass.com/mcp under Connection, set authentication to none and select Create as a plugin. In a chat, type @ and pick walletmcppass. Availability depends on your plan and workspace settings. Full steps.
Codex
Claude Code
OpenAI Responses API
Any other MCP client: add a remote Streamable HTTP server at https://walletmcppass.com/mcp, with no headers. No MCP client? Use the REST API.
Then ask for a pass:
- "Make an Apple Wallet boarding pass from this PDF." (with the PDF attached)
- "Turn my climbing gym card into a Wallet pass. Member number 004217, Code 128 barcode."
- "Create an event ticket for Blue Note on Friday at 9pm, seat B12, with this QR code text: TICKET-0042."
PDF boarding pass to Apple Wallet
Give your assistant the airline's PDF or a screenshot and it builds a Wallet boarding pass. Boarding passes are built from your real airline-issued barcode: the pass carries the same barcode data as your PDF (usually a PDF417 or Aztec code holding an IATA BCBP string), which is what security and gate scanners read. The service doesn't issue tickets, check you in or contact airlines, and the pass won't receive gate changes. Assistants read a PDF's printed text reliably but can't always decode the barcode image; if yours can't, scan it with a barcode scanner app and paste the text.
Compared with Create a Pass in iOS 27
Wallet in iOS 27 can scan a card or fill in a Standard, Membership or Event template on the iPhone, which covers a single simple card. wallet-mcp adds:
- boarding pass, coupon and store card layouts, Apple's iOS 26 semantic boarding pass and the iOS 27 poster layout;
- passes built by an assistant or app from a PDF, an email or a sentence, instead of a form on the phone;
- control over the barcode format (QR, PDF417, Aztec, Code 128 everywhere; Code 39, Codabar, EAN-13, ITF on iOS 27), colors, logo, images and back-of-pass text;
- a signed
.pkpass file you can share, or add on an iPhone that isn't running iOS 27.
Privacy, terms and support
walletmcppass.com is an independent project, not affiliated with Apple. Pass contents aren't written to the request log, and pass files are deleted when their link expires after 1 hour; the request log keeps only the time, IP address, pass style and organization name, plus the contact email and x402 status every call must send. Updatable passes are stored for their update window (1 hour by default, at most 30 days) and then deleted. Questions and bug reports: GitHub issues.
Why this exists
Building a .pkpass by hand means writing pass.json, generating icon/logo art, hashing every file into a manifest, signing it with an Apple Pass Type ID certificate, and zipping it up flat — a fiddly, easy-to-get-wrong process (see the "gotchas" below). This wraps that whole pipeline behind a single MCP tool call, so an LLM agent (or you, through one) can just ask for a pass.
How it works
One process, one port, four things on it:
- The MCP endpoint (Streamable HTTP) at
/mcp — the create_wallet_pass tool.
- The REST API at
POST /api/passes — the same pass builder for non-MCP callers. It shares the rate limits and request log with the tool; each logged request records which one it came through (source = mcp or api).
- A usage dashboard at
/ and /api/stats — request counts, success/error rate, recent activity, all read from a local SQLite log. The hosted instance doesn't expose these publicly.
- Short-lived download links at
/download/{token} — built passes are served with the correct application/vnd.apple.pkpass content type (required for Wallet to recognize the file) and expire after 1 hour, swept by file mtime so a crash/restart can't leak files.
The tool: create_wallet_pass
Annotated with title: "Create Apple Wallet pass", readOnlyHint: false and destructiveHint: false: it only ever creates a new pass file.
Returns { download_url, expires_in_seconds, serial_number, pass_type_identifier }. The download link is valid for one hour. Open it in Safari on the iPhone; Wallet won't add a .pkpass handed over by another app. Google Wallet on Android imports the same file.
The REST API: POST /api/passes
The JSON body takes exactly the tool's parameters above. The request model is generated from the same function signature, so the two can't drift apart. Unknown keys are rejected.
As with the tool, bodies that fail schema validation are rejected before the rate limiter and aren't logged; pass-builder errors are logged and do count.
Updating a pass: update_wallet_pass / PATCH /api/passes/{serial}
Create the pass with updatable=True, and optionally updatable_hours (1 to 720, default 1) for how long it can be updated. The response adds updatable_until and an edit_token, a secret that is the only way to change that pass (no accounts; whoever holds the token owns the pass). The pass carries a webServiceURL ({PUBLIC_BASE_URL}/passkit) and its own authenticationToken, so every iPhone that adds it registers with the server.
An update takes serial_number, edit_token and any of the create parameters. Given parameters replace the stored ones whole (a fields list replaces the list); omitted ones are kept. The server re-signs the pass, stores it, and sends an empty APNs push to each registered device. Wallet then fetches the new version. voided=True is how you cancel a pass. A field's changeMessage (for example "You now have %@ points") shows on the lock screen when that field's value changes. Wallet matches fields by key, so give changing fields an explicit key.
It returns the create response plus notified_devices and updatable_until (pushes APNs accepted, not devices that have refreshed yet). The status codes are 200, 400, 401 (no bearer token), 402, 404 (unknown serial, wrong token, or update window over, deliberately the same) and 429. Updates count towards the same daily limits as creates.
Notes:
- Only updatable passes store their contents (in the
passes table, with the edit token as a SHA-256 hash). The window is counted from creation and updates don't extend it. When it ends, the pass is treated as gone at once, and the download sweeper deletes the row and its device registrations within 30 seconds. Copies in Wallet keep their last version. Image URLs are fetched once and stored as PNG, so an update never refetches them.
- The PassKit web service (
/passkit/v1/...) implements Apple's protocol: register and unregister a device, list changed serials (passesUpdatedSince is the pass's integer updated time), fetch the latest pass (If-Modified-Since gives 304), and log. Each pass accepts up to 100 devices.
- APNs uses the pass-signing certificate over HTTP/2 via
curl, because the venv has no HTTP/2 client. WALLET_MCP_APNS_URL overrides the endpoint, and the tests use that to point it at a fake. A push that gets 400 or 410 removes that device's token.
- nginx must forward
/passkit/ and /api/passes/ to the backend, alongside /mcp, /download/ and = /api/passes.
- Tests:
tests/test_updates.py.
Image URLs
Every *_png_b64 parameter also accepts an https:// URL. image_fetch.py fetches it with SSRF guards: https only; every resolved address must be globally routable (so no loopback, private, link-local or CGNAT/tailnet addresses); the connection is pinned to the checked IP; redirects are re-checked (at most 3); and the limits are 5 MB, 25 megapixels and 10 s per read. JPEG, WebP, GIF and ICO are converted to PNG. Base64 input keeps its old rule, which is PNG only.
Deploying your own instance
You need your own Apple Developer Pass Type ID certificate — the one in this repo's design (pass_builder.py) is not included, and can't be, since it's a private key tied to a specific Apple Developer account. To stand this up:
- Issue a Pass Type ID certificate in the Apple Developer portal, export the private key + signed cert as PEM, and download Apple's WWDR G4 intermediate cert.
- In
pass_builder.py, point SIGNING_DIR/PASSKEY/CERT/WWDR at your files, and set PASS_TYPE_IDENTIFIER/TEAM_IDENTIFIER to your own.
- Set up the Python environment (this project has no
pip/venv assumptions baked in beyond standard tooling):
- Run it:
WALLET_MCP_PUBLIC_HOST matters: the MCP SDK has DNS-rebinding protection on by default and will reject every request with a 421 unless the request's Host header is in the allowed list — set this to whatever hostname the server is actually reached at.
- Put a reverse proxy (nginx, Caddy, etc.) in front of it with TLS, exposing
/mcp, /download/ and /api/passes (but not / or /api/stats, which are the private dashboard). If you use nginx, the Streamable HTTP transport needs:
Buffering has to be off or the SSE stream stalls through the proxy.
- Run it under a process supervisor so it survives crashes and reboots —
wallet-mcp.service in this repo is a working example (systemd, Restart=on-failure, WantedBy=multi-user.target).
Rate limiting
No API key — anyone with the URL can call the tool or the REST API. Protected only by daily caps in db.py: 30 requests/day per caller IP (dropping to 20 on 2026-11-18, set by IP_LIMIT_DROP_DAY in db.py; setting WALLET_MCP_IP_LIMIT pins the limit and cancels the drop), 500 free calls/day globally as a backstop (paid calls excluded), counted across MCP and REST calls combined (override with WALLET_MCP_IP_LIMIT / WALLET_MCP_GLOBAL_LIMIT; WALLET_MCP_DB moves the sqlite file, which the tests use). If you put this behind a hosted MCP client (e.g. a claude.ai connector) rather than direct calls, be aware many end users can share one apparent IP on the server side, so the per-caller cap won't isolate them individually — the global cap is what actually protects you in that case.
Paying past the free limit: x402
Both tools and the REST API also require two inputs that are only logged, as requests columns: contact_email (the user's address, so the operator can reach them about the service; a missing, malformed or placeholder one is a 400 that is logged as refused but doesn't count as a call) and x402_ready (true, false or "unknown", which shows how many clients could pay past the free limit; separate from the x402 column, which records whether a call actually sent a payment).
Set WALLET_MCP_X402_PAY_TO to a Base address and a caller past either free limit (per-IP or global) is offered the call for $0.01 in USDC instead of being refused. Paid calls don't count towards the global free budget. They have their own caps: 1,000 a day per IP and 10,000 in total (WALLET_MCP_PAID_IP_LIMIT / WALLET_MCP_PAID_GLOBAL_LIMIT), past which it's a plain 429. Without it, they get the plain 429 as before. payments.py uses the x402 SDK:
- MCP: the refused call is a tool error whose
structuredContent is an x402 v2 PaymentRequired (resource mcp://tool/create_wallet_pass or update_wallet_pass). The client retries the same call with the signed payment in _meta["x402/payment"]. The result then carries the receipt in _meta["x402/payment-response"] and as a payment key.
- REST: a 402 with the
PaymentRequired body and PAYMENT-REQUIRED header. The client retries with a PAYMENT-SIGNATURE (or legacy X-PAYMENT) header and gets the receipt in PAYMENT-RESPONSE and as payment.
The facilitator verifies the payment before any work. It settles it on-chain after the pass is built but before the pass is handed over, stored or pushed. So a failed build is never charged, and a failed settlement (402 again) gets nothing and changes nothing. Payments sent while under the limit are ignored, not charged. Each settled call is logged with its payment_tx. The server never holds a key; the USDC goes straight to the payout address.
Coinbase's CDP facilitator also needs CDP API-key auth headers, which aren't wired in. tests/test_x402.py runs against a fake facilitator. --live also sends an unfunded payment to PayAI on mainnet, which must be refused for lack of balance.
Capacity guards
These stop any caller, paying or not, from taking the box down:
- A built pass over 10 MB is refused with a 400 (
WALLET_MCP_MAX_PASS_KB). Without images a pass is ~16 KB; the size comes from images.
- Under 5 GB of free disk, every new create or update gets a 503 (
WALLET_MCP_MIN_FREE_DISK_GB).
- At most 4 builds run at once (
BUILD_LIMITER), and the rest queue. Image decoding is the memory-heavy part.
- systemd backstop in
wallet-mcp.service: MemoryHigh=1G, MemoryMax=1536M, CPUQuota=200%. A runaway build gets this service OOM-killed and restarted, not the box.
The usage watcher (a separate cron) emails when any of these trip, when systemd restarts the service, or when free disk drops under 10 GB.
Gotchas worth knowing before you touch pass_builder.py or image_gen.py
barcodes is a top-level key in pass.json, a sibling of boardingPass/eventTicket/etc — not nested inside the style dict. Nesting it there is silently ignored: the pass installs fine, but there's no barcode anywhere on the card.
- Serve
.pkpass files with Content-Type: application/vnd.apple.pkpass explicitly. Both Python's mimetypes module and most static file servers don't know this extension and fall back to application/octet-stream, which makes Wallet (and Mail/browsers) treat it as a generic download instead of offering "Add to Apple Wallet."
- Field keys must be unique across a pass's sections. Auto-generated keys used to restart at
field0 in every section; iOS rejects many such passes outright (the pass just won't open), though some combinations slip through, which hides the bug. Keys are now section-prefixed (primary0, secondary0, ...) with collisions suffixed.
- The Interleaved 2 of 5 barcode format string is
PKBarcodeFormatI2of5, not PKBarcodeFormatITF as several WWDC26 write-ups claim. apple/pass-builder is the authoritative reference for the iOS 27 keys.
- Apple's docs and Apple's own pass-builder code disagree on the semantic boarding pass time-zone keys:
departureLocationTimeZone (docs) vs departureAirportTimeZone (code), same for destination. Both are accepted and emitted as given; send both.
- Apple's Pass Builder validator flags a poster event ticket without NFC as an error, and the docs say the design isn't meant for barcode entry. NFC needs an Apple entitlement this deployment doesn't have, so those passes may show as classic event tickets.
- Some barcodes (e.g. many airline boarding passes) are Aztec codes, not QR — visually similar but with one bullseye finder pattern instead of three corner squares. Match
barcode_format to what you're actually encoding.
MCP Registry
server.json describes the hosted instance for the official MCP Registry as com.walletmcppass/wallet-mcp.
Status
Live at walletmcppass.com. See wallet-mcp.service for the deployment shape it runs under.