There is a lot of material on generating a .pkpass file. There is almost none on the part that matters: keeping it current. A loyalty card that shows a stale stamp count, an event ticket that cannot be moved to a new gate, a membership that cannot be revoked — these are the same failure, and it is a failure of infrastructure, not of design.
This guide covers the complete update path. It is written from the implementation that runs Labs Loyalty's own Wallet cards in production — signing, the web service, and push are all live there — and it deliberately spends most of its length on the three things that actually break: the web service contract, APNs over mutual-TLS, and a handful of undocumented behaviours that will cost you days if you meet them cold.
An Apple Wallet push carries no content. It is an empty background signal that means "something changed, go and ask." The device then calls your web service to fetch the new pass. Almost every wrong mental model of Wallet updates starts by assuming the push contains the update.
1. What a pass actually is
A .pkpass is a ZIP archive. Inside it:
pass.json— the content and structure- images —
icon.png,logo.png, and type-specific art such asstrip.png, each at 1x/2x/3x manifest.json— a SHA-1 of every other file in the archivesignature— a detached PKCS#7 signature over that manifest
The signature is what makes it a pass rather than a zip. Change one byte of any file without re-signing and the device rejects it silently.
The five pass types
boardingPass, coupon, eventTicket, storeCard, generic. The type determines the layout and which image slots exist. You cannot invent one, and you cannot change a pass's type after issue.
footerFields is not a real PassKit field group. The valid set is headerFields, primaryFields, secondaryFields, auxiliaryFields and backFields (plus additionalInfoFields, but only on poster event tickets). Popular libraries accept footerFields without complaint and silently drop it, so the field simply never appears — on any pass, ever, with no error anywhere. The confusion is understandable: Apple's own Human Interface Guidelines talk about a "footer" as a visual layout slot (the barcode/category line on some pass styles) — that's a real part of the design language, it just isn't a key you can write to in pass.json. If a field is mysteriously missing, unzip a real signed pass and read pass.json before you debug anything else.
2. Signing
You need three things from Apple: a Pass Type ID, a signing certificate for it, and the Apple WWDR intermediate certificate. The signature chains your certificate to Apple's root.
To make a pass updatable, pass.json must carry two extra keys:
{
// ... your normal pass content ...
"webServiceURL": "https://api.example.com",
"authenticationToken": "<per-pass secret, 16+ chars>"
}
Apple appends /v1/devices/... and /v1/passes/... to whatever you put in webServiceURL itself — it does not add a version prefix for you beyond that. Put /v1 in the value, as the example above used to, and every request lands on /v1/v1/devices/... unless your own routing happens to mount a second /v1 to match. The value should be the bare origin (optionally with a path prefix of your own choosing, e.g. https://api.example.com/passkit) — never a copy of the version segment Apple is about to add.
Two secrets are involved, not one, and they cover different calls. authenticationToken is the bearer credential above, sent as Authorization: ApplePass <token> on register, unregister and fetch-pass. The device's own deviceLibraryIdentifier — not a secret, just an opaque per-device id you chose when it registered — is what the "what changed?" endpoint below is keyed on. Treat the auth token as a real bearer credential: generate it randomly per pass, store its hash, and never reuse one across passes.
Not 365. When it lapses you cannot issue new passes and you cannot update existing ones — every pass already in circulation silently freezes, though it keeps displaying whatever it last showed (this is expiry, a soft failure — see the harder revocation case in §6). Apple emails the account as the date approaches, but don't make your renewal depend on an email landing in the right inbox: put the date in a calendar the day you create the certificate.
3. The web service
Once webServiceURL is present, the device starts talking to you. Five endpoints, and Apple's spec is exact about all of them. {deviceId} below is shorthand — the real path segment is deviceLibraryIdentifier, which is what you'll see in the actual spec and in any library's route table.
| Method & path | What it means |
|---|---|
POST /v1/devices/{deviceId}/registrations/{passTypeId}/{serial} | Device is asking for updates. Body contains its APNs pushToken. Store it. |
DELETE — same path | Pass was removed. Delete the registration or you will push into the void forever. |
GET /v1/devices/{deviceId}/registrations/{passTypeId}?passesUpdatedSince=<tag> | "What changed?" Return the serials that changed plus a new lastUpdated tag. |
GET /v1/passes/{passTypeId}/{serial} | Serve a freshly signed .pkpass. This is the actual update. |
POST /v1/log | Device-side error log, sent unprompted. Easy to skip in a short guide — but the spec has five endpoints, not four, and this is the one people forget exists. |
Two behaviours worth knowing before you go looking for bugs:
- The device calls
GET /v1/passes/...a few seconds after every install, unprompted. That is not a bug and not a push — it is the device confirming it has the current version. - Return
204 No Contentfrom the updates check when nothing changed — Apple's current docs define this explicitly as "No Matching Passes," so this is spec, not folklore. Returning200with an empty list instead is a documented cause of some iOS versions re-fetching every pass unnecessarily. (Tested against recent iOS only — older versions reportedly logged their own complaints about a bare 204, so don't assume this is universally silent across every iOS release still in the wild.)
4. The push channel — the part that breaks
This is where implementations die. Wallet push does not use a normal APNs auth key. It authenticates with your pass certificate as a TLS client certificate — mutual TLS, against api.push.apple.com.
The payload is empty. The apns-topic is your Pass Type ID, not a bundle ID. The push type is background.
// Deno — mutual-TLS client cert on the fetch client
const client = Deno.createHttpClient({
cert: signerCertPem, // your pass certificate, PEM
key: signerKeyPem, // its private key, PEM
});
await fetch(`https://api.push.apple.com/3/device/${pushToken}`, {
method: "POST",
client,
headers: {
"apns-topic": PASS_TYPE_ID, // NOT a bundle id
"apns-push-type": "background",
"apns-priority": "5", // required: default (10) + background is a documented APNs error
},
body: "{}", // deliberately empty
});
Don't skip apns-priority: 5. APNs' default priority is 10, and 10 paired with a background push type is a documented rejection — the frustrating version of this bug returns a plain 200 from APNs while the device does nothing at all, so it looks like your push worked right up until you check whether the pass actually updated.
Client-certificate support is the least-travelled path in most HTTP stacks. You will hit runtime-specific limitations here that have nothing to do with Wallet, and the failure mode is usually an opaque TLS error rather than a useful HTTP status. Get a single push working end-to-end before you build anything on top of it — this is the step to de-risk first, not last.
The full update sequence
- Your data changes.
- You look up every registration for that pass serial.
- You send an empty push to each device token.
- The device calls your
passesUpdatedSinceendpoint. - You answer with the changed serials.
- The device fetches each pass fresh.
- You sign a new
.pkpassat request time and return it.
Note step 7. You are not storing pass files and serving them — you are regenerating and re-signing on demand. Build your pass generation as a pure function of your data from the very beginning, or you will rewrite it later.
5. Gotchas worth the time they cost
Serve the right content type
application/vnd.apple.pkpass. Get it wrong and the device downloads a zip instead of offering to add a pass.
Images are not optional
A missing icon.png produces a pass that fails to install with no error message. The icon is what appears in notifications and the lock screen, so it is required even though nothing tells you so.
Test on a real device
The Simulator's Wallet behaviour diverges from a real phone, particularly around registration and push. A pass that installs and updates in the Simulator proves very little.
Verify by unzipping, not by trusting
Pull a real signed pass from your live endpoint, unzip it, and read pass.json and the image bytes. A 200 response tells you your server worked; it tells you nothing about whether the pass is correct. This is how the footerFields problem above gets found.
Gateway auth in front of your functions
If your web service sits behind a platform that verifies its own auth tokens by default, Apple's calls will be rejected before your code runs — Apple sends Authorization: ApplePass <token>, which is not your platform's scheme. You will see a gateway 401 and no logs at all. Deploy these endpoints with platform auth disabled and do the ApplePass check yourself.
6. The certificate question, before you build a business on it
If you are issuing passes for other people, the constraint is not technical. Apple's position is that the entity owning the certificate should be the entity issuing and redeeming the passes. Issuing on a client's behalf means holding a written agreement authorising it, and putting a clear disclaimer on the back of the pass stating you are authorised and where it can be redeemed. Apple can revoke certificates for violations.
| You hold the certificate | Client holds it | |
|---|---|---|
| Onboarding | Immediate | Client needs their own developer account |
| Branding | Passes stack; shared lock-screen icon | Correct per-brand icon |
| Blast radius | One revocation affects everyone | Isolated |
| Renewals | One clock | One clock per client |
Do not confuse this with certificate expiry above — they are different failure modes with very different blast radii. Expiry is the soft case: installed passes keep showing whatever they last displayed, you simply lose the ability to sign new passes or push updates until you renew. Revocation is not the same thing with a different name. Apple's own certificate documentation is explicit about it: once a certificate is revoked, passes signed with it "will no longer function properly" — not "stop updating," stop functioning. A revocation can break passes that are already sitting on someone's lock screen, not just freeze them in place. If you are issuing passes for other people, write the revocation risk into your terms as a real, hard failure — not the same soft "it'll just go stale" degradation the certificate-expiry callout above describes.
A pass that emulates a card to a physical reader — door entry, transit, access control — needs an Apple Developer account specifically approved to issue NFC Pass Type Identifier Certificates. Apple reviews your business and use case, approval takes roughly two to four weeks, and you must supply a public encryption key for Value Added Services. Apply early; it runs in parallel with everything else.
Labs Pass
Everything above — signing, the five web service endpoints, and mutual-TLS push — is the update path running Labs Loyalty's own Wallet cards in production today. A packaged, MIT-licensed reference implementation is in progress; this page will link to it the day it ships, not before.
More from the Labs