All articles
// developers

Generate an Apple Wallet Pass in Node.js, End to End

Certificates, the manifest, the signature, and the update web service: everything a Node.js Apple Wallet integration needs beyond generation.

Updated
Generate an Apple Wallet Pass in Node.js, End to End
Julio Song
11 min read

It's 11pm on a Tuesday, and a developer we'll call Marcus has a working .pkpass file sitting on his desk. He built it in an evening: a Node script, a JSON template, a barcode. He AirDrops it to his phone, taps Add, and there it is, a concert ticket sitting in Apple Wallet next to his boarding passes. He closes his laptop feeling like the hard part is over.

It isn't. Two weeks later a teammate installs the pass on an Android phone and gets nothing, because Apple Wallet and Google Wallet are two separate systems with two separate signing chains. A month after that, the gate number for a real event changes twenty minutes before doors open, and every ticket already sitting in a fan's Wallet still shows the old gate, because nobody built a way to push an update. A year after that, the Pass Type ID certificate expires on a Tuesday nobody was watching, and every new pass silently stops signing.

None of these are edge cases. They're the default outcome of stopping at generation, which is exactly where almost every tutorial, and every open-source library, stops.

Generating a single pass is the easy part of this integration. It's also the part every tutorial covers, because it's the part you can finish in an evening. The rest, the certificate chain, the manifest and signature that make the file valid, the web service that pushes live updates, and doing all of it again for Google, is the part that decides whether your Wallet integration survives contact with real users.

What "generate a pass" actually involves

An Apple Wallet pass is a signed ZIP archive with the extension .pkpass. Inside it: a pass.json describing the fields, colors, and barcode, a set of image assets, a manifest.json that hashes every file in the bundle, and a signature file that proves the manifest hasn't been tampered with. Wallet won't install a pass unless all four pieces check out.

To produce that signature you need two certificates: a Pass Type ID certificate issued to your Apple Developer account, and Apple's own WWDR intermediate certificate, which chains your signature back to Apple so iOS trusts it. Getting both means enrolling in the Apple Developer Program, registering a Pass Type ID, exporting a .p12, and converting a private key into a format Node can actually use.

None of that is optional, and none of it is the part developers usually plan for. A tutorial that ends at "here's your .pkpass file" has covered maybe a third of what a production pass integration needs.

Break the full job into its pieces and it looks less like one task and more like five separate ones stacked on top of each other: certificate management, pass construction, cryptographic signing, hosting and serving the file correctly, and a long-running update service that has nothing to do with any of the previous four. Skip the last one and your integration works perfectly during the demo and quietly stops helping anyone six months later, once the first real-world change needs to reach a pass that's already in someone's pocket.

The certificate chain, once

The Apple Developer Program costs $99 a year. That membership is what lets you create a Pass Type ID in the developer portal, under Certificates, Identifiers & Profiles → Identifiers, where you register something like pass.com.example.event. From there you generate a Certificate Signing Request on your own machine, upload it under Certificates, and download the signing certificate that comes back, a .cer file you export from Keychain Access alongside its private key as a single .p12 bundle.

Node's crypto tooling doesn't want that key in .p12 form. Most Wallet libraries expect PEM, and the private key specifically needs to be in PKCS#8 format rather than the older PKCS#1 format OpenSSL exports by default:

openssl pkcs12 -in Certificates.p12 -nocerts -out key.pem -nodes
openssl pkcs8 -topk8 -nocrypt -in key.pem -out key.pkcs8.pem
openssl pkcs12 -in Certificates.p12 -clcerts -nokeys -out cert.pem

You also need Apple's WWDR intermediate certificate, downloaded once from the developer portal, and it rarely changes. It last rotated in February 2023, when the previous intermediate expired and any server with the old chain hardcoded suddenly couldn't validate signatures until it picked up the renewed one. The new chain doesn't expire until 2030.

Your own Pass Type ID certificate is a different story. It expires every year, on a fixed date nobody puts on a calendar, and when it does, every pass you try to sign after that date fails silently until someone notices and renews it.

A wax-sealed envelope with a small key chained to it, standing in for the certificate and private key pair every signed pass needs

Signing a pass with Node.js

Once the three files exist, the signing step itself is small. Using passmint, an open-source TypeScript library built for exactly this:

npm install passmint
import { Pass, SigningMaterial } from "passmint"

const material = await SigningMaterial.fromPem({
  signerCertPem: process.env.APPLE_PASS_CERT!,
  wwdrPem: process.env.APPLE_WWDR_CERT!,
  privateKeyPkcs8Pem: process.env.APPLE_PASS_KEY!,
})

const signed = await Pass.eventTicket({
  passTypeIdentifier: "pass.com.example.event",
  serialNumber: "riverside-2026-00482",
  teamIdentifier: "ABCD1234EF",
  organizationName: "Riverside Music Festival",
  description: "General admission",
  images: { icon: { x2: { bytes: iconPng } } },
  barcodes: [{ format: "qr", message: "TICKET-00482" }],
})
  .primaryField({ key: "event", label: "Event", value: "Riverside Music Festival" })
  .secondaryField({ key: "gate", label: "Gate", value: "C" })
  .sign(material)

// signed.toUint8Array(), signed.toStream(), or signed.toResponse()

passkit-generator, the older and more widely used library in this space, does the same job with a slightly different API, reading certificates and a model folder to produce a signed buffer. Neither library, and this matters, touches anything past the signature. Both stop the moment you have bytes.

Why the manifest and signature exist at all

Wallet doesn't trust a .pkpass because it came from your server. It trusts the signature. When a phone opens the file, it recomputes the SHA-1 hash of every file inside, compares each one against manifest.json, then checks that the detached PKCS#7 signature over that manifest was produced by a certificate chaining back to Apple's WWDR root. Change one pixel in your icon after signing, and the hash no longer matches. Re-sign with the wrong key, and the chain doesn't resolve. Either way, Wallet rejects the pass with an error message that tells the holder nothing useful and tells you even less.

This is also why you can't hand-edit a signed pass, ever, not even to fix a typo. The signature covers the exact bytes you signed. Any change means re-signing from scratch.

Most of the signature errors you'll hit trace back to one of a handful of causes: the WWDR certificate you signed with doesn't match the one currently trusted by the device, the private key and the signing certificate don't actually belong to the same pair, or a build step touched an image file after the manifest was generated. iOS reports all three the same way, as a pass that silently fails to install, which is why it's worth logging the manifest hashes on your own server before you ship a pass out the door.

A phone glowing on a nightstand at night next to a blank ticket stub, representing the silent APNs wake-up push that tells Wallet to fetch an update

Getting the file to a phone at all

A correctly signed .pkpass still won't install if your server serves it wrong. Safari and Mail decide whether to hand a download off to Wallet based on the response headers, not the file extension, so the route that returns your pass needs Content-Type: application/vnd.apple.pkpass. Add Content-Disposition: attachment; filename=ticket.pkpass too, since without it some browsers render the raw archive instead of triggering the Wallet preview. Get the content type wrong and you get a downloaded file nobody knows what to do with. The whole exchange has to happen over HTTPS too, since Wallet won't register a device against a plain HTTP web service URL.

This is a small detail, and it's the one that eats an afternoon the first time, because a missing header produces no error message at all. The pass simply doesn't offer to install, and there's nothing in the response to tell you why.

The part no generation library touches: pushing an update

Say the gate for that Riverside Music Festival ticket changes from C to A, twenty minutes before doors open. The pass sitting in a fan's Wallet right now was signed once, at issuance. Nothing about a static .pkpass file updates itself.

Apple's answer is a web service protocol your server has to implement, separate from anything passmint or passkit-generator does for you. When a holder adds a pass, their phone registers a device and push token against your server:

POST /v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier/:serialNumber
Authorization: ApplePass <authenticationToken from pass.json>

Your server stores that mapping. When the gate changes, you don't push new data directly, you send an empty APNs notification to the stored push token. That notification carries no payload at all. It's purely a wake-up call: Wallet receives it, then calls back into two more endpoints on your server. First GET /v1/devices/:deviceLibraryIdentifier/registrations/:passTypeIdentifier?passesUpdatedSince=<tag>, which your server answers with the serial numbers that changed and a fresh tag, then GET /v1/passes/:passTypeIdentifier/:serialNumber, which returns the full re-signed .pkpass for each changed serial, or a 304 Not Modified if nothing actually changed since the tag it sent. Only after that round trip does the pass on the lock screen update.

You still need a running APNs client, a device registration store, a way to serve the latest pass data on demand, and a web service that stays reachable for as long as any pass you've ever issued might still be installed. Uninstalling a pass triggers a DELETE to the same registration endpoint, and a well-behaved server prunes the device record then, or the next push just wastes an APNs call on a token nobody's listening on anymore.

Every generation library, passmint included, ends at sign(). The web service is the part you build yourself, and it's usually the part that turns "we shipped Apple Wallet support" into "we shipped Apple Wallet support that works six months later."

And then you do the equivalent work for Google Wallet

Google Wallet skips the ZIP-and-signature model entirely. Instead of a .pkpass file, you build a JSON pass object, sign it into a JWT with a Google Cloud service account, and hand the holder a save link:

import { Pass, GoogleSigningMaterial } from "passmint"

const google = await GoogleSigningMaterial.fromServiceAccount({
  clientEmail: serviceAccount.client_email,
  privateKeyPkcs8Pem: serviceAccount.private_key,
  issuerId: "3388000000000000",
})

const pass = Pass.eventTicket({
  passTypeIdentifier: "pass.com.example.event",
  serialNumber: "riverside-2026-00482",
  teamIdentifier: "ABCD1234EF",
  organizationName: "Riverside Music Festival",
  description: "General admission",
  images: { icon: { x2: { bytes: iconPng } } },
  barcodes: [{ format: "qr", message: "TICKET-00482" }],
}).build()

const url = await pass.toGoogleSaveLink(google, {
  origins: ["example.com"],
})
// → "https://pay.google.com/gp/v/save/<jwt>"

There's no manifest, no PKCS#7 signature, no WWDR chain. There's also no reuse of the certificate work you just did for Apple. The service account comes from a Google Cloud project instead of the Apple Developer portal, and the identifiers are different too: an issuer ID for your account and a class ID for the pass type, rather than a Pass Type ID and a Team Identifier.

Updates work differently as well. Instead of an APNs wake-up call, you patch the pass object directly through Google's API, and the change reflects in the holder's Wallet without a separate poll-and-fetch round trip. That's a genuinely simpler model, but it's still a second implementation, with its own auth, its own rate limits, and its own failure modes to monitor. A library that outputs both formats from one schema, passmint does this, saves you from maintaining two field mappings by hand, but the signing infrastructure and the update logic are still two separate systems you're responsible for running.

Two toy robots, one badged with an apple and one with a colorful pinwheel, each holding a differently shaped ticket to represent building the same pass twice for two separate wallets

Where this actually leaves you

Add it up: an annual certificate you have to track yourself, a manifest and signature step that fails hard on any mismatch, correct headers on the route that serves the file, a persistent web service for updates that has nothing to do with generation, an APNs client, and a second, unrelated pipeline for Google. None of it is exotic engineering. All of it is infrastructure you have to build and keep running, on top of a feature that's usually meant to be a small part of a much larger product.

That's a reasonable trade when passes are the product. A ticketing platform or a loyalty app justifies owning every layer of this. It's a much harder trade for a team that just needs three field values to show up on a customer's lock screen and would rather not staff a certificate renewal calendar to get there.

If you'd rather stop at pass.create() and skip the certificate renewals, the device registry, and the APNs client entirely, Passmint's quickstart issues a signed pass, on both wallets, in about five minutes.

Primary sources

Common questions

A pass.json describing fields, colours, and the barcode; the image assets; a manifest.json holding a SHA-1 hash of every file; and a signature file signing that manifest. Apple Wallet refuses to install a pass unless all four check out.
Yes. Signing requires a Pass Type ID certificate and the Apple WWDR intermediate, both of which expire and need renewal. A managed platform holds and rotates these for you.
Implement the pass update web service, register the device when the pass is added, and send an APNs push when a field changes. The device then fetches the new pass from your endpoint.
Julio Song

Technical content writer, Passmint

Julio is a technical content writer at Passmint. He writes about Apple PassKit, the Google Wallet API, and what breaks when wallet passes meet production traffic.

More from Julio Song

Related reading

Share this article