# Passlet > One API for Apple Wallet and Google Wallet passes. Define a pass once and get .pkpass for Apple, JWT for Google.

Apple Wallet and Google Wallet passes from one TypeScript API

Issue, update and bundle passes with zero runtime dependencies.

npm CI License: MIT TypeScript: strict

Playground  ·  Docs  ·  Examples  ·  Changelog

## What is this? Apple and Google both put cards in a phone's wallet: loyalty cards, tickets, boarding passes. They agree on nothing else. Apple wants a signed `.pkpass` archive; Google wants a JWT backed by a REST API, with different field names, layouts and rules. Passlet lets you describe a pass once. One call returns a signed `.pkpass` for Apple and a save link for Google, and one more keeps both up to date on the holder's phone. ## Quick start ```sh npm install passlet ``` ```ts import { field, Wallet } from "passlet"; const wallet = new Wallet({ apple: appleCredentials, google: googleCredentials }); const rewards = wallet.loyalty({ id: "rewards", name: "Coffee Club", apple: { icon: "https://example.com/icon.png" }, google: { logo: "https://example.com/logo.png" }, fields: [field.primary("points", "Points")], }); const pass = await rewards.create({ serialNumber: "member-123", values: { points: "1250" } }); // pass.apple → .pkpass bytes · pass.google → JWT for googleSaveUrl() ``` Runs on the server, Node.js 20.15+. Leave out either wallet to use just one. Getting credentials: [Credentials](https://github.com/oscartrevio/passlet/wiki/Credentials). ## Pass types | Method | For | Apple | Google | |---|---|---|---| | `loyalty()` | Rewards and memberships | Store card | Loyalty | | `eventTicket()` | Concerts, matches, conferences | Event ticket | Event ticket | | `boardingPass()` | Flights, trains, buses, ferries | Boarding pass | Flight or transit | | `coupon()` | Offers and discounts | Coupon | Offer | | `giftCard()` | Prepaid balances | Store card | Gift card | | `generic()` | Anything else | Generic | Generic | ## Features - Apple and Google from one definition: a signed `.pkpass` and a Google save link from the same call - Fields on the front and back, with date, number and currency formatting - Barcodes: QR, PDF417, Aztec, Code 128 and more, plus rotating codes on Google - Images from URLs or bytes: icons, logos, strips, thumbnails and Google hero images - Translations for every field, in both wallets - Lock-screen relevance by date and location, and NFC (Apple NFC passes, Google Smart Tap) - Updates after issue, with a notification on the holder's phone when a value changes - Several passes in one download, like a family's tickets or every leg of a trip ## Documentation - [Getting started](https://github.com/oscartrevio/passlet/wiki/Getting-Started): first pass for both wallets - [Credentials](https://github.com/oscartrevio/passlet/wiki/Credentials): Apple certificates and Google service accounts - [Pass types](https://github.com/oscartrevio/passlet/wiki/Pass-Types): what each type supports on each wallet - [Fields and layout](https://github.com/oscartrevio/passlet/wiki/Fields-and-Layout): slots, formatting, change messages - [Images, barcodes and localization](https://github.com/oscartrevio/passlet/wiki/Images-Barcodes-and-Localization) - [Serving passes](https://github.com/oscartrevio/passlet/wiki/Serving-Passes): headers, save links, caching - [Several passes at once](https://github.com/oscartrevio/passlet/wiki/Multiple-Passes): bundles and grouping - [Updating passes](https://github.com/oscartrevio/passlet/wiki/Updating-Passes): `load`, the web service, notifications - [Deploying](https://github.com/oscartrevio/passlet/wiki/Deploying) and [External signers](https://github.com/oscartrevio/passlet/wiki/External-Signers) - [Errors](https://github.com/oscartrevio/passlet/wiki/Errors) and [FAQ](https://github.com/oscartrevio/passlet/wiki/FAQ) - [Examples](https://github.com/oscartrevio/passlet/blob/main/examples/README.md): Express, Hono and Next.js servers - [Upgrading from v2](https://github.com/oscartrevio/passlet/releases/tag/passlet%403.0.0) - For agents: [llms.txt](https://passlet.oscartrev.io/llms.txt) and the [skill](https://passlet.oscartrev.io/skill.md) ## Contributing Issues and PRs welcome. Setup, tests and releases are in [CONTRIBUTING.md](https://github.com/oscartrevio/passlet/blob/main/CONTRIBUTING.md); report security issues as described in [SECURITY.md](https://github.com/oscartrevio/passlet/blob/main/SECURITY.md). ## License [MIT](https://github.com/oscartrevio/passlet/blob/main/LICENSE) ---
Built by [Oscar Treviño](https://www.oscartrev.io) [X](https://twitter.com/oscartrevio_) | [GitHub](https://github.com/oscartrevio)
--- # Passlet examples Three minimal servers that issue the **same** loyalty pass and serve it over HTTP: The examples install `passlet` from npm and target passlet v3. | Example | Stack | Endpoints | | ------------------------ | ------------------------- | --------------------------------------------------------------- | | [`nextjs/`](https://github.com/oscartrevio/passlet/blob/main/examples/nextjs) | Next.js 15 App Router | `GET /api/passes/:serial/apple` · `GET /api/passes/:serial/google` | | [`hono/`](https://github.com/oscartrevio/passlet/blob/main/examples/hono) | Hono + `@hono/node-server` | `GET /passes/:serial/apple` · `GET /passes/:serial/google` | | [`express/`](https://github.com/oscartrevio/passlet/blob/main/examples/express) | Express 5 | `GET /passes/:serial/apple` · `GET /passes/:serial/google` | Each one does exactly two things: - **Apple** — responds with the signed `.pkpass` bytes (`Uint8Array`) under `Content-Type: application/vnd.apple.pkpass`. - **Google** — responds `302` to `https://pay.google.com/gp/v/save/`. ## Run one ```bash cd examples/hono # or nextjs / express cp .env.example .env # fill in your credentials pnpm install pnpm dev open http://localhost:3000/passes/user-123/google ``` Each folder ships a 1×1 placeholder `assets/icon.png` so the examples boot before you have real artwork. Replace it with a real icon (29×29, 58×58 `@2x`, 87×87 `@3x`) before shipping — Apple renders it in the Wallet list view. ## The shape of the API All three examples follow the same three steps: ```ts const wallet = new Wallet({ apple: {...}, google: {...} }); // once, at boot const card = wallet.loyalty({ id, name, fields: [...] }); // reusable template const { apple, google } = await card.create({ // per recipient serialNumber: "user-123", values: { points: "1250" }, barcode: { format: "QR", value: "user-123" }, }); ``` - `apple` is a `Uint8Array` — the `.pkpass` archive, or `null` if you left Apple credentials out. - `google` is a JWT `string` — or `null` if you left Google credentials out. - A named image that fails to load rejects `create()` with a `WalletError`. With both wallets configured, its `results` show what the other wallet returned. `new Wallet()` and `wallet.loyalty()` are cheap and side-effect free; only `create()` does signing and network I/O. Build the template at module scope and call `create()` per request. ## Environment variables Every example reads the same set (see each folder's `.env.example`). ### Apple | Variable | Where it comes from | | ---------------------------- | ------------------------------------------------------------------ | | `APPLE_PASS_TYPE_IDENTIFIER` | Your Pass Type ID, e.g. `pass.com.yourcompany.rewards` | | `APPLE_TEAM_ID` | 10-character Apple Team ID | | `APPLE_SIGNER_CERT` | PEM **contents** of the pass signing certificate | | `APPLE_SIGNER_KEY` | PEM **contents** of the matching private key | | `APPLE_WWDR` | PEM **contents** of the Apple WWDR G4 intermediate certificate | 1. Create a [Pass Type ID](https://developer.apple.com/account/resources/identifiers/list/passTypeId) and download its signing certificate. 2. Export it as `.p12`, then convert to the PEM pair Passlet expects: ```bash openssl pkcs12 -in certificate.p12 -clcerts -nokeys -out signerCert.pem openssl pkcs12 -in certificate.p12 -nocerts -out signerKey.pem ``` If `signerKey.pem` is passphrase-protected, strip the passphrase (`openssl rsa -in signerKey.pem -out signerKey.pem`) — Passlet takes an unencrypted key. 3. Download the [Apple WWDR G4 certificate](https://www.apple.com/certificateauthority/). It ships as DER (`.cer`); convert it: ```bash openssl x509 -inform der -in AppleWWDRCAG4.cer -out wwdr.pem ``` Passlet wants the PEM **text**, not a path. In `.env`, wrap each value in quotes and keep the `\n` escapes, or load the files with `readFileSync(..., "utf8")` at boot. ### Google | Variable | Where it comes from | | --------------------- | ---------------------------------------------------------- | | `GOOGLE_ISSUER_ID` | Pay & Wallet Console → Google Wallet API → Settings | | `GOOGLE_CLIENT_EMAIL` | `client_email` in the service-account JSON key | | `GOOGLE_PRIVATE_KEY` | `private_key` in the same JSON key (PKCS#8 PEM) | | `GOOGLE_LOGO_URL` | Publicly reachable HTTPS URL of your logo (required) | 1. Sign up at the [Google Pay & Wallet Console](https://pay.google.com/business/console). 2. In Google Cloud, enable the **Google Wallet API** and create a service account. 3. Grant that service-account email access to your issuer account in the Wallet Console. 4. Store `private_key` in `GOOGLE_PRIVATE_KEY`. Passlet restores literal `\n` escapes automatically; no manual replacement is needed. Set `google.origins` to the domains that embed your "Add to Google Wallet" button, otherwise the web save button won't render. **Credentials are server-only.** Never generate a pass in the browser, never ship a signing key to the client. ## Header rules ### Apple (`.pkpass`) ```http Content-Type: application/vnd.apple.pkpass Content-Disposition: attachment; filename="user-123.pkpass" Content-Length: 24576 Cache-Control: no-store, private ``` - **The MIME type must be exactly `application/vnd.apple.pkpass`.** Not `application/octet-stream`, not `application/zip`, no `; charset=utf-8` suffix. iOS Safari decides whether to open the "Add Pass" sheet purely from this header — get it wrong and the user gets a raw download or a blank page. - `Content-Disposition: attachment` with a `.pkpass` **filename**, quoted. iOS keys off the content type, but Android/desktop browsers and email clients key off the extension. Sanitize the filename if the serial number is user-controlled. - Send the bytes verbatim. Don't run the response through a JSON serializer, don't re-encode as base64, don't let a framework helper (`res.send`, `res.json`) guess a type for you. - `Content-Length` is optional but helps clients that dislike chunked binary responses. ### Google (save link) ```http HTTP/1.1 302 Found Location: https://pay.google.com/gp/v/save/ Cache-Control: no-store, private ``` The save URL is nothing more than the JWT from `create()` appended to `https://pay.google.com/gp/v/save/`. You can either redirect (as these examples do) or return the URL as JSON and let the client render an official ["Add to Google Wallet" button](https://developers.google.com/wallet/generic/resources/brand-guidelines). ## Common pitfalls - **Caching.** Passes are per-recipient and signed; a shared cache or CDN will hand user A's pass to user B. Always send `Cache-Control: no-store, private`. In Next.js also set `export const dynamic = "force-dynamic"` — route handlers can otherwise be statically cached at build time, and a cached `302` to a stale Google JWT is a very confusing bug. - **Runtime.** Passlet signs with Node's built-in `crypto` and `zlib`. In Next.js set `export const runtime = "nodejs"` (the Edge runtime will fail). On Cloudflare Workers you need `nodejs_compat`. - **Required images.** Apple needs `apple.icon`, as bytes or a URL. Google loyalty and transit passes need `google.logo` as a public URL; Google does not accept image uploads. Missing required images throw `APPLE_MISSING_ICON` or `GOOGLE_MISSING_LOGO`. - **Certificate mismatch.** `APPLE_PASS_TYPE_IDENTIFIER` and `APPLE_TEAM_ID` must match the certificate exactly. A mismatch still produces a `.pkpass`, but iOS silently refuses to add it — no error, just a dead file. - **Serve over HTTPS.** iOS will download a pass from `http://localhost` during development, but real devices and email links need TLS. To test on a phone, tunnel (`ngrok`, `cloudflared`) rather than using your LAN IP. - **Unique serial numbers.** `serialNumber` identifies the pass for its whole life. Reusing one overwrites the previous holder's Google object and confuses Apple's update flow. Use your own user/ticket ID. - **`values: { key: null }` hides a field** for that recipient — it does not clear the label. Omit the key entirely to fall back to the template's default value. - **Images must load.** Every image a template names is fetched by `create()`, and any failure rejects the call. Pass bytes instead of URLs to avoid that network step. - **Template publication.** Google issuance creates missing classes but does not overwrite existing ones. Call `pass.publish()` after changing shared template configuration, from setup or deployment code rather than on each download. - **Errors are typed.** Catch `WalletError` around construction and async operations. It exposes `code`, `status`, `message`, `why`, and `fix`, plus validation `issues` and optional `retryAfter` seconds. Branch on `code`; keep underlying causes and recipient data out of public responses. ## Example sources ### examples/express/.env.example Source: https://github.com/oscartrevio/passlet/tree/main/examples/express ```sh # Apple Wallet — see examples/README.md for how to obtain these APPLE_PASS_TYPE_IDENTIFIER="pass.com.yourcompany.rewards" APPLE_TEAM_ID="ABCD1234EF" # PEM contents, not file paths. Multi-line values must be quoted. APPLE_SIGNER_CERT="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" APPLE_SIGNER_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" APPLE_WWDR="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" # Google Wallet — from the Pay & Wallet Console + service-account JSON key GOOGLE_ISSUER_ID="3388000000000000000" GOOGLE_CLIENT_EMAIL="passes@your-project.iam.gserviceaccount.com" # Keep the literal \n escapes; the app restores real newlines at runtime. GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" # Google accepts hosted image URLs only. GOOGLE_LOGO_URL="https://cdn.example.com/logo.png" ``` ### examples/express/src/server.ts Source: https://github.com/oscartrevio/passlet/tree/main/examples/express/src ```ts import { readFileSync } from "node:fs"; import express, { type NextFunction, type Request, type Response, } from "express"; import { APPLE_PASS_CONTENT_TYPE, field, googleSaveUrl, Wallet, WalletError, } from "passlet"; const icon = readFileSync(new URL("../assets/icon.png", import.meta.url)); const wallet = new Wallet({ // Omit either provider entirely to ship single-platform. apple: { passTypeIdentifier: requireEnv("APPLE_PASS_TYPE_IDENTIFIER"), teamId: requireEnv("APPLE_TEAM_ID"), signerCert: requireEnv("APPLE_SIGNER_CERT"), signerKey: requireEnv("APPLE_SIGNER_KEY"), wwdr: requireEnv("APPLE_WWDR"), }, google: { issuerId: requireEnv("GOOGLE_ISSUER_ID"), clientEmail: requireEnv("GOOGLE_CLIENT_EMAIL"), privateKey: requireEnv("GOOGLE_PRIVATE_KEY"), origins: ["http://localhost:3000"], }, }); // A reusable template, validated eagerly (throws PASS_CONFIG_INVALID at boot). const rewardsCard = wallet.loyalty({ id: "rewards-card", name: "Acme Rewards", color: "#1c1917", fields: [ field.primary("points", "Points"), // value comes from create() field.secondary("tier", "Tier", "Gold"), field.back("terms", "Terms", "Points expire after 12 months."), ], apple: { icon, foregroundColor: "#fafaf9", labelColor: "#a8a29e" }, // Google takes hosted URLs only, and requires a logo. google: { logo: requireEnv("GOOGLE_LOGO_URL"), issuerName: "Acme Inc." }, }); const app = express(); // Unannotated handlers let Express type `params.serial` from the route path. app.get("/passes/:serial/apple", async (req, res) => { const serial = req.params.serial; const { apple } = await rewardsCard.create({ serialNumber: serial, values: { points: "1250" }, barcode: { format: "QR", value: serial, altText: serial }, }); if (!apple) { res.status(501).send("Apple Wallet is not configured"); return; } // res.end() preserves the explicit headers without adding an ETag. res.setHeader("Content-Type", APPLE_PASS_CONTENT_TYPE); res.setHeader( "Content-Disposition", `attachment; filename="${sanitize(serial)}.pkpass"` ); res.setHeader("Content-Length", apple.byteLength); res.setHeader("Cache-Control", "no-store, private"); res.end(Buffer.from(apple)); }); app.get("/passes/:serial/google", async (req, res) => { const serial = req.params.serial; const { google } = await rewardsCard.create({ serialNumber: serial, values: { points: "1250" }, barcode: { format: "QR", value: serial }, }); if (!google) { res.status(501).send("Google Wallet is not configured"); return; } // Save links are recipient-specific; keep the redirect uncached. res.setHeader("Cache-Control", "no-store, private"); res.redirect(302, googleSaveUrl(google)); }); // Express 5 forwards rejected async handlers here automatically. app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { if (error instanceof WalletError) { console.error(error.code, error.message); res.status(500).send(`Pass generation failed: ${error.code}`); return; } console.error(error); res.status(500).send("Internal Server Error"); }); app.listen(3000, () => { console.log("Listening on http://localhost:3000"); console.log(" /passes/user-123/apple"); console.log(" /passes/user-123/google"); }); function requireEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`Missing environment variable: ${name}`); } return value; } /** Keep the Content-Disposition filename well-formed for any serial number. */ function sanitize(value: string): string { return value.replace(/[^\w.-]/g, "_"); } ``` ### examples/hono/.env.example Source: https://github.com/oscartrevio/passlet/tree/main/examples/hono ```sh # Apple Wallet — see examples/README.md for how to obtain these APPLE_PASS_TYPE_IDENTIFIER="pass.com.yourcompany.rewards" APPLE_TEAM_ID="ABCD1234EF" # PEM contents, not file paths. Multi-line values must be quoted. APPLE_SIGNER_CERT="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" APPLE_SIGNER_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" APPLE_WWDR="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" # Google Wallet — from the Pay & Wallet Console + service-account JSON key GOOGLE_ISSUER_ID="3388000000000000000" GOOGLE_CLIENT_EMAIL="passes@your-project.iam.gserviceaccount.com" # Keep the literal \n escapes; the app restores real newlines at runtime. GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" # Google accepts hosted image URLs only. GOOGLE_LOGO_URL="https://cdn.example.com/logo.png" ``` ### examples/hono/src/server.ts Source: https://github.com/oscartrevio/passlet/tree/main/examples/hono/src ```ts import { readFileSync } from "node:fs"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { APPLE_PASS_CONTENT_TYPE, field, googleSaveUrl, Wallet, WalletError, } from "passlet"; // Use the Node adapter; Apple pass signing requires node:crypto. const icon = readFileSync(new URL("../assets/icon.png", import.meta.url)); const wallet = new Wallet({ // Omit either provider entirely to ship single-platform. apple: { passTypeIdentifier: requireEnv("APPLE_PASS_TYPE_IDENTIFIER"), teamId: requireEnv("APPLE_TEAM_ID"), signerCert: requireEnv("APPLE_SIGNER_CERT"), signerKey: requireEnv("APPLE_SIGNER_KEY"), wwdr: requireEnv("APPLE_WWDR"), }, google: { issuerId: requireEnv("GOOGLE_ISSUER_ID"), clientEmail: requireEnv("GOOGLE_CLIENT_EMAIL"), privateKey: requireEnv("GOOGLE_PRIVATE_KEY"), origins: ["http://localhost:3000"], }, }); // A reusable template, validated eagerly (throws PASS_CONFIG_INVALID at boot). const rewardsCard = wallet.loyalty({ id: "rewards-card", name: "Acme Rewards", color: "#1c1917", fields: [ field.primary("points", "Points"), // value comes from create() field.secondary("tier", "Tier", "Gold"), field.back("terms", "Terms", "Points expire after 12 months."), ], apple: { icon, foregroundColor: "#fafaf9", labelColor: "#a8a29e" }, // Google takes hosted URLs only, and requires a logo. google: { logo: requireEnv("GOOGLE_LOGO_URL"), issuerName: "Acme Inc." }, }); const app = new Hono(); app.get("/passes/:serial/apple", async (c) => { const serial = c.req.param("serial"); const { apple } = await rewardsCard.create({ serialNumber: serial, values: { points: "1250" }, barcode: { format: "QR", value: serial, altText: serial }, }); if (!apple) { return c.text("Apple Wallet is not configured", 501); } return new Response(apple, { headers: { // iOS Safari only triggers the "Add Pass" sheet for this exact type. "Content-Type": APPLE_PASS_CONTENT_TYPE, "Content-Disposition": `attachment; filename="${sanitize(serial)}.pkpass"`, "Content-Length": String(apple.byteLength), "Cache-Control": "no-store, private", }, }); }); app.get("/passes/:serial/google", async (c) => { const serial = c.req.param("serial"); const { google } = await rewardsCard.create({ serialNumber: serial, values: { points: "1250" }, barcode: { format: "QR", value: serial }, }); if (!google) { return c.text("Google Wallet is not configured", 501); } // Save links are recipient-specific; keep the redirect uncached. return new Response(null, { status: 302, headers: { Location: googleSaveUrl(google), "Cache-Control": "no-store, private", }, }); }); app.onError((error, c) => { if (error instanceof WalletError) { console.error(error.code, error.message); return c.text(`Pass generation failed: ${error.code}`, 500); } console.error(error); return c.text("Internal Server Error", 500); }); serve({ fetch: app.fetch, port: 3000 }, (info) => { console.log(`Listening on http://localhost:${info.port}`); console.log(" /passes/user-123/apple"); console.log(" /passes/user-123/google"); }); function requireEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`Missing environment variable: ${name}`); } return value; } /** Keep the Content-Disposition filename well-formed for any serial number. */ function sanitize(value: string): string { return value.replace(/[^\w.-]/g, "_"); } ``` ### examples/nextjs/.env.example Source: https://github.com/oscartrevio/passlet/tree/main/examples/nextjs ```sh # Apple Wallet — see examples/README.md for how to obtain these APPLE_PASS_TYPE_IDENTIFIER="pass.com.yourcompany.rewards" APPLE_TEAM_ID="ABCD1234EF" # PEM contents, not file paths. Multi-line values must be quoted. APPLE_SIGNER_CERT="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" APPLE_SIGNER_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" APPLE_WWDR="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" # Google Wallet — from the Pay & Wallet Console + service-account JSON key GOOGLE_ISSUER_ID="3388000000000000000" GOOGLE_CLIENT_EMAIL="passes@your-project.iam.gserviceaccount.com" # Keep the literal \n escapes; the app restores real newlines at runtime. GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" # Google accepts hosted image URLs only. GOOGLE_LOGO_URL="https://cdn.example.com/logo.png" ``` ### examples/nextjs/app/api/passes/[serial]/apple/route.ts Source: https://github.com/oscartrevio/passlet/tree/main/examples/nextjs/app/api/passes/[serial]/apple ```ts import { APPLE_PASS_CONTENT_TYPE, WalletError } from "passlet"; import { rewardsCard } from "@/lib/wallet"; // Apple pass signing requires the Node.js runtime. export const runtime = "nodejs"; // Passes are per-recipient and freshly signed — never let Next cache them. export const dynamic = "force-dynamic"; export async function GET( _request: Request, { params }: { params: Promise<{ serial: string }> } ) { const { serial } = await params; try { const { apple } = await rewardsCard.create({ serialNumber: serial, values: { points: "1250" }, barcode: { format: "QR", value: serial, altText: serial }, }); if (!apple) { return new Response("Apple Wallet is not configured", { status: 501 }); } return new Response(apple, { headers: { "Content-Type": APPLE_PASS_CONTENT_TYPE, "Content-Disposition": `attachment; filename="${sanitize(serial)}.pkpass"`, "Content-Length": String(apple.byteLength), // Passes contain personal data; never cache them in a shared proxy. "Cache-Control": "no-store, private", }, }); } catch (error) { if (error instanceof WalletError) { console.error(error.code, error.message); return new Response(`Pass generation failed: ${error.code}`, { status: 500, }); } throw error; } } /** Keep the filename header well-formed for arbitrary serial numbers. */ function sanitize(value: string): string { return value.replace(/[^\w.-]/g, "_"); } ``` ### examples/nextjs/app/api/passes/[serial]/google/route.ts Source: https://github.com/oscartrevio/passlet/tree/main/examples/nextjs/app/api/passes/[serial]/google ```ts import { googleSaveUrl, WalletError } from "passlet"; import { rewardsCard } from "@/lib/wallet"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET( _request: Request, { params }: { params: Promise<{ serial: string }> } ) { const { serial } = await params; try { const { google } = await rewardsCard.create({ serialNumber: serial, values: { points: "1250" }, barcode: { format: "QR", value: serial }, }); if (!google) { return new Response("Google Wallet is not configured", { status: 501 }); } return new Response(null, { status: 302, headers: { Location: googleSaveUrl(google), // Save links are recipient-specific; keep the redirect uncached. "Cache-Control": "no-store, private", }, }); } catch (error) { if (error instanceof WalletError) { console.error(error.code, error.message); return new Response(`Pass generation failed: ${error.code}`, { status: 500, }); } throw error; } } ``` ### examples/nextjs/app/layout.tsx Source: https://github.com/oscartrevio/passlet/tree/main/examples/nextjs/app ```tsx export const metadata = { title: "Passlet — Next.js example" }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } ``` ### examples/nextjs/app/page.tsx Source: https://github.com/oscartrevio/passlet/tree/main/examples/nextjs/app ```tsx // Minimal demo page. In a real app the serial number would come from the // signed-in user, not from a hardcoded string. const serial = "user-123"; export default function Home() { return (

Passlet — Next.js example

); } ``` ### examples/nextjs/lib/wallet.ts Source: https://github.com/oscartrevio/passlet/tree/main/examples/nextjs/lib ```ts import { readFileSync } from "node:fs"; import { join } from "node:path"; import { field, Wallet } from "passlet"; // Server-only: signing credentials must never enter a client bundle. const icon = readFileSync(join(process.cwd(), "assets/icon.png")); // Omit either provider to issue passes for only one platform. export const wallet = new Wallet({ apple: { passTypeIdentifier: requireEnv("APPLE_PASS_TYPE_IDENTIFIER"), teamId: requireEnv("APPLE_TEAM_ID"), signerCert: requireEnv("APPLE_SIGNER_CERT"), signerKey: requireEnv("APPLE_SIGNER_KEY"), wwdr: requireEnv("APPLE_WWDR"), }, google: { issuerId: requireEnv("GOOGLE_ISSUER_ID"), clientEmail: requireEnv("GOOGLE_CLIENT_EMAIL"), privateKey: requireEnv("GOOGLE_PRIVATE_KEY"), // Domains that embed the "Add to Google Wallet" button. origins: ["http://localhost:3000"], }, }); // Reuse the template across recipients; configuration is validated at construction. export const rewardsCard = wallet.loyalty({ id: "rewards-card", name: "Acme Rewards", color: "#1c1917", fields: [ // No value here — supplied per recipient via `values` in create(). field.primary("points", "Points"), field.secondary("tier", "Tier", "Gold"), field.back("terms", "Terms", "Points expire after 12 months."), ], apple: { icon, foregroundColor: "#fafaf9", labelColor: "#a8a29e" }, // Google requires a hosted logo URL. google: { logo: requireEnv("GOOGLE_LOGO_URL"), issuerName: "Acme Inc." }, }); function requireEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`Missing environment variable: ${name}`); } return value; } ```