@nikpnevmatikos/pass-generator
v0.1.0
Published
Create passes for Apple Wallet (.pkpass) and Google Wallet from one wallet-agnostic ticket model.
Maintainers
Readme
@nikpnevmatikos/pass-generator
Create passes for Apple Wallet (.pkpass) and Google Wallet from one wallet-agnostic model. Describe a pass once — issue it to either wallet. Supports event tickets, boarding passes, coupons, store/loyalty cards, and generic passes.
It's an embeddable TypeScript library: you import a function and get a pass. No server required.
import { createApplePass, createGoogleWalletSaveUrl } from "@nikpnevmatikos/pass-generator";| Wallet | You call | You get |
| --- | --- | --- |
| Apple | createApplePass(ticket, …) | a signed .pkpass Buffer — email or serve it |
| Google | createGoogleWalletSaveUrl(ticket, …) | a "Save to Google Wallet" URL (a signed JWT) |
Both take the same WalletPass — a union of pass kinds selected by a kind field (omit it for an event ticket, so existing code keeps working).
Install
npm install @nikpnevmatikos/pass-generatorRequires Node ≥ 18. Developing on the repo itself instead? npm install && npm run build.
Quick start
Apple Wallet
import { createApplePass, loadCertificatesFromDir, type WalletTicket } from "@nikpnevmatikos/pass-generator";
import { writeFileSync } from "node:fs";
const ticket: WalletTicket = {
serialNumber: "TCKT-2026-0001", // unique per pass
description: "Concert ticket",
organizationName: "Acme Tickets",
eventName: "Summer Synth Festival",
venueName: "Technopolis, Athens",
date: new Date("2026-07-18T20:00:00+03:00"),
holderName: "Alex Taylor",
ticketType: "VIP",
seat: { section: "A", row: "3", seat: "12" },
barcode: { format: "qr", message: "TCKT-2026-0001|verify-token" },
colors: { background: "#1c1c28", foreground: "#ffffff", label: "#b6b6c8" },
images: { icon: "./assets/icon.png", logo: "./assets/logo.png" },
};
const pkpass = await createApplePass(ticket, {
identity: { passTypeIdentifier: "pass.com.example.ticket", teamIdentifier: "ABCDE12345" },
certificates: loadCertificatesFromDir("./certs"),
});
writeFileSync("ticket.pkpass", pkpass); // application/vnd.apple.pkpassGoogle Wallet
import { createGoogleWalletSaveUrl, loadGoogleCredentials } from "@nikpnevmatikos/pass-generator";
const url = createGoogleWalletSaveUrl(ticket, {
credentials: loadGoogleCredentials({
serviceAccountPath: "./certs/google-service-account.json",
issuerId: "3388000000XXXXXXXXX", // numeric Issuer ID
}),
});
// -> https://pay.google.com/gp/v/save/<jwt> — put it behind an "Add to Google Wallet" buttonThe same ticket drives both. Google-specific branding (logoUrl, heroImageUrl, homepageUrl) is optional — see the model below.
The WalletPass model
WalletPass is a discriminated union — pick a kind and fill in its fields. A pass without a kind is treated as an eventTicket, so existing WalletTicket code keeps working (WalletTicket is an alias for the event-ticket variant).
type PassKind = "eventTicket" | "boardingPass" | "coupon" | "storeCard" | "generic";Shared fields — every kind has these; only a few are required:
| Field | Required | Notes |
| --- | --- | --- |
| serialNumber | ✅ | Unique id for this pass |
| description | ✅ | Short text, also used for accessibility (Apple) |
| organizationName | ✅ | Your brand / issuer name |
| kind | — | Pass type; omit for eventTicket. See Pass types for kind-specific fields |
| barcode | ✅ | { format: "qr" \| "pdf417" \| "aztec" \| "code128", message, altText? } |
| images.icon | ✅ for Apple | PNG path or Buffer; @2x/@3x via { "1x":…, "2x":… } |
| date, expirationDate | — | date drives Apple lock-screen relevance / Google validity start |
| logoText, colors | — | Colours accept hex (#1c1c28) or rgb(28, 28, 40) |
| backFields | — | { label, value }[] — back of the Apple pass / Google text rows |
| locations | — | { latitude, longitude, relevantText? }[] (Apple) |
| logoUrl, heroImageUrl, homepageUrl | — | Google only — hosted HTTPS URLs (logo, banner, website). Apple uses embedded images. |
| webServiceURL + authenticationToken | — | Advanced — Apple push updates |
| userInfo | — | Arbitrary data round-tripped in the Apple pass |
Why the split for images? Apple embeds PNGs inside the signed
.pkpass; Google references images by public URL. So Apple readsimages(paths/Buffers) and Google readslogoUrl/heroImageUrl.
Need the raw Apple pass.json without signing (for previews/tests)? Call buildApplePassJson(pass, identity).
Pass types
Set kind to choose the type. Each maps to an Apple style and a Google pass type:
| kind | Apple style | Google type | Kind-specific fields (✅ = required) |
| --- | --- | --- | --- |
| eventTicket (default) | eventTicket | Event ticket | eventName ✅, venueName, doorsOpen, holderName, seat, ticketType |
| boardingPass | boardingPass | Transit | transitType ✅, origin ✅, destination ✅, passengerName, boardingTime, gate, seat, carrierName, tripNumber |
| coupon | coupon | Offer | offerName ✅, provider, terms, promoCode |
| storeCard | storeCard | Loyalty | programName ✅, memberName, memberId, balance, tier |
| generic | generic | Generic | title, subtitle, headerFields, primaryFields, secondaryFields, auxiliaryFields |
transitTypeis"air" \| "train" \| "bus" \| "boat" \| "generic".origin/destinationare{ code?, name }, e.g.{ code: "ATH", name: "Athens" }.seatis{ section?, row?, seat?, admissionLevel? }.headerFields/primaryFields/ … are{ label, value }[].
Boarding passes map to Google Transit, not Flight. Flight requires airline-only fields (carrier code, IATA airport codes, scheduled datetimes) a train/bus can't supply. Two consequences: Google Transit has no "air" bucket, so
transitType: "air"shows asOTHER; and Google requires a logo on transit passes, so setlogoUrlfor boarding passes. (Full airline Flight support is on the roadmap.)
Google requires a
logoforboardingPassandstoreCard. BothtransitClassandloyaltyClassmandate a logo, socreateGoogleWalletSaveUrlthrows iflogoUrlis missing for those kinds. (Coupons, event tickets, and generic passes don't need one.) Apple is unaffected — it uses the embeddedimagesinstead.
Examples
Every kind goes through the same createApplePass / createGoogleWalletSaveUrl shown above — only the model changes.
import type { WalletPass } from "@nikpnevmatikos/pass-generator";
const boarding: WalletPass = {
kind: "boardingPass",
serialNumber: "BP-1", description: "Boarding pass", organizationName: "Acme Air",
transitType: "air",
origin: { code: "ATH", name: "Athens" },
destination: { code: "LHR", name: "London Heathrow" },
passengerName: "Alex Taylor", tripNumber: "AA 178", gate: "B12", seat: { seat: "14C" },
barcode: { format: "pdf417", message: "BP-1|token" },
images: { icon: "./assets/icon.png" },
logoUrl: "https://example.com/logo.png", // required for Google transit
};
const coupon: WalletPass = {
kind: "coupon",
serialNumber: "CPN-1", description: "20% off", organizationName: "Acme Coffee",
offerName: "20% off any coffee", promoCode: "BREW20", terms: "One per customer.",
barcode: { format: "qr", message: "CPN-1" },
images: { icon: "./assets/icon.png" },
};
const card: WalletPass = {
kind: "storeCard",
serialNumber: "LOY-1", description: "Rewards card", organizationName: "Acme Rewards",
programName: "Acme Rewards", memberId: "AR-99823", balance: "1,250 pts", tier: "Gold",
barcode: { format: "qr", message: "LOY-1" },
images: { icon: "./assets/icon.png" },
};
const generic: WalletPass = {
kind: "generic",
serialNumber: "GEN-1", description: "Gym locker", organizationName: "Acme Gym",
title: "Locker 42", subtitle: "Members' area",
primaryFields: [{ label: "Locker", value: "42" }],
barcode: { format: "qr", message: "GEN-1" },
images: { icon: "./assets/icon.png" },
};See examples/pass-types.ts for a runnable version of all four.
Apple Wallet
Prerequisites
- Apple Developer Program membership ($99/year).
- A Pass Type ID and its certificate → see below.
- Node ≥ 18.
Creating your certificate (Windows + openssl)
openssl ships with Git for Windows. Run these from the project root.
1 — Register the Pass Type ID. developer.apple.com/account → Certificates, Identifiers & Profiles → Identifiers → + → Pass Type IDs → pick an id like pass.com.you.ticket. Note it, and your Team ID (10 chars, Membership page).
2 — Generate a private key + CSR.
openssl genrsa -out certs/pass.key 2048
openssl req -new -key certs/pass.key -out certs/pass.csr -subj "/CN=My Pass/[email protected]/C=US"3 — Create the certificate. Open your Pass Type ID → Create Certificate → upload certs/pass.csr → download pass.cer.
4 — Convert to PEM.
openssl x509 -inform DER -in certs/pass.cer -out certs/signerCert.pem
# Apple WWDR intermediate (G4) from https://www.apple.com/certificateauthority/ :
openssl x509 -inform DER -in certs/AppleWWDRCAG4.cer -out certs/wwdr.pemEnd state — loadCertificatesFromDir("./certs") expects exactly this (folder is gitignored):
certs/
├── signerCert.pem ← Pass Type ID certificate
├── pass.key ← private key
└── wwdr.pem ← Apple WWDR (G4) intermediateIf your key is encrypted, set APPLE_PASS_KEY_PASSPHRASE.
Distributing a .pkpass
Deliver it with the right MIME type and iOS shows an "Add to Wallet" sheet:
- Email — attach the
.pkpass. - Download link —
Content-Type: application/vnd.apple.pkpass.
res.setHeader("Content-Type", "application/vnd.apple.pkpass");
res.setHeader("Content-Disposition", 'attachment; filename="ticket.pkpass"');
res.send(pkpass);Google Wallet
No file to sign: you define a class (per-event template) and an object (the ticket), embed them in a JWT signed with your service-account key, and hand the user a Save link. Free to test.
Prerequisites — one-time setup
You need one thing from each of two Google consoles.
A. Google Cloud Console → the service-account key (console.cloud.google.com)
- Pick or create a project.
- Enable the Wallet API: console.cloud.google.com/apis/library/walletobjects.googleapis.com → Enable.
- Create a service account: console.cloud.google.com/iam-admin/serviceaccounts/create → name it → Done. Copy its email (
…@project.iam.gserviceaccount.com). - Download a JSON key: open the service account → KEYS → ADD KEY → Create new key → JSON. 🔒 Secret — treat it like your Apple
pass.key.
B. Google Pay & Wallet Console → the Issuer ID + authorization (pay.google.com/business/console)
- Sign in (create an issuer account if you don't have one).
- Find your Issuer ID in the Google Wallet API section — it's a long number (e.g.
3388000000022…). ⚠️ Not theBCR2DN…Google Pay merchant ID — that's a different identifier and will fail. - Authorize the service account: Users → Invite a user → paste the service-account email from step 3 → access level Developer → Invite. (Must be done here, not in Cloud IAM.)
- Add a test account: new issuers are in demo mode, so passes only save to allow-listed accounts. Add the Google account you'll test with (Users, or the "test accounts" list under the Wallet API dashboard).
Usage
const credentials = loadGoogleCredentials({
serviceAccountPath: "./certs/google-service-account.json",
issuerId: "3388000000XXXXXXXXX", // or set GOOGLE_WALLET_ISSUER_ID
});
const url = createGoogleWalletSaveUrl(ticket, { credentials /*, classSuffix, origins */ });Add branding via ticket.logoUrl / heroImageUrl / homepageUrl (hosted HTTPS URLs) and colors.background.
Testing
Open the Save URL signed into your allow-listed Google account (Android Chrome, or desktop browser → syncs to your phone). Demo-mode passes show a [TEST ONLY] banner — that's expected until your issuer is approved for production.
Common errors
- "Service account … did not have edit access on the issuer" → step 7 (invite as Developer), or the Issuer ID doesn't match the issuer where the account is authorized.
- Wrong Issuer ID → make sure it's the numeric one from step 6, not the
BCR2DN…merchant ID.
Running the example
# Apple (optional — falls back to an unsigned preview if no certs):
# $env:APPLE_PASS_TYPE_ID="pass.com.you.ticket"; $env:APPLE_TEAM_ID="ABCDE12345"
# Google (optional — needs certs/google-service-account.json):
# $env:GOOGLE_WALLET_ISSUER_ID="3388000000XXXXXXXXX"
# $env:PASS_LOGO_URL="https://…/logo.png" # optional branding
npm run example # event ticket (both wallets)
npm run example:types # boarding pass, coupon, store card, generic- Apple: writes a signed
.pkpassper pass (or an unsigned<serial>.preview.jsonif no certs). - Google: writes a
<serial>.google-save-url.txtper pass (if a service account + issuer id are present).
The examples generate their own placeholder PNGs, so they run with zero asset files.
Run the pure mapping tests (no certs or network needed) with npm test.
Apple vs Google at a glance
| | Apple | Google |
| --- | --- | --- |
| Output | signed .pkpass file | "Save" URL (JWT) |
| Auth | Pass Type ID certificate | service-account key |
| Images | embedded PNGs | hosted URLs |
| Colours | background + foreground + label | single hexBackgroundColor |
| Cost to test | $99/yr (cert required) | free (demo mode) |
Project structure
src/
├── index.ts # public API
├── model/ticket.ts # WalletPass union (+ WalletTicket alias)
├── apple/
│ ├── builder.ts # pass -> signed .pkpass (uses passkit-generator)
│ ├── pass-json.ts # pass -> Apple pass.json (per-kind fields & props)
│ ├── images.ts # image slots -> { filename: Buffer }
│ └── certificates.ts # load + validate signing material
└── google/
├── builder.ts # pass -> signed "Save to Google Wallet" URL
├── pass-object.ts # pass -> per-kind Class + Object (event/transit/offer/loyalty/generic)
└── credentials.ts # load issuer id + service-account key
examples/
├── event-ticket.ts # runnable demo — event ticket (both wallets)
└── pass-types.ts # runnable demo — boarding / coupon / storeCard / generic
test/mappings.test.ts # pure mapping tests (no certs/network)
certs/ # your signing material (gitignored)Security
- Never commit
certs/. It holds your Apple private key and Google service-account key — anyone with them can issue passes as you. It's gitignored; keep it that way and load secrets from a secret manager in production. - Treat the Apple
authenticationToken(if you use push updates) as a secret too.
License
MIT © Nik Pnevmatikos
