I had a drawer full of gift cards and paper vouchers, and I never had the right one with me. So I spent a Saturday building a small self-hosted web app: take a photo of the card, and it turns the barcode into an Apple Wallet pass on my phone. It is now a Docker container on my home server. Here is what it took.

What a .pkpass actually is

A Wallet pass is a zip file with a different extension. Inside you need:

  • pass.json: the pass type, colours, fields and the barcode payload
  • images such as icon.png, logo.png and strip.png, in 1x and 2x sizes
  • manifest.json: a SHA-1 hash of every other file in the package
  • signature: a detached CMS (PKCS#7) signature over the manifest

The signature is the part that stops you doing this with a shell script. It must be made with a Pass Type ID certificate from your Apple Developer account, and the signing chain has to include Apple’s WWDR intermediate certificate. If any of that is wrong, the phone silently refuses to open the pass with no error message.

Reading the barcode

I started with zbarimg from the command line. It is fine on a clean screenshot, but photographs of curved plastic cards under kitchen lighting defeated it more often than not. Switching to zbar-wasm inside the Node app was more reliable, removed a system dependency from the container, and let me try a few rotations and crops before giving up.

import { scanImageData } from "zbar-wasm";
const symbols = await scanImageData(imageData);
// symbols[0].typeName -> "ZBAR_CODE128", symbols[0].decode() -> "6012 3456 7890"

The detected symbology matters. Wallet supports QR, PDF417, Aztec and Code 128. If the card uses something else, say EAN-13, you have to render the number as text instead and let the cashier type it.

Signing the pass

I used passkit-generator, which handles the manifest and signature for you. You give it a model folder with your images and a template pass.json, plus the certificates.

const pass = await PKPass.from({
  model: "./models/storecard.pass",
  certificates: { wwdr, signerCert, signerKey, signerKeyPassphrase },
}, {
  serialNumber: crypto.randomUUID(),
  description: "Gift card",
});
pass.setBarcodes({ message: code, format: "PKBarcodeFormatCode128",
                   messageEncoding: "iso-8859-1" });
pass.primaryFields.push({ key: "balance", label: "Balance", value: "£25.00" });
res.type("application/vnd.apple.pkpass").send(pass.getAsBuffer());

The content type header is important. Safari on iOS only hands the download to Wallet when it sees application/vnd.apple.pkpass.

The rest of the app

Around that core there is a login, a library page listing every pass I have made, and a detail view showing the card number and PIN in large text for the vouchers where the barcode alone is not enough. Everything lives in one SQLite file, and the whole thing is published through my reverse proxy so I can add a card from my phone while standing in the shop.

Twenty commits in one day, and the drawer is empty. If you have a developer account already, this is one of the more satisfying weekend projects I have done.