# 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.
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;
}
```