Building a Web Service for Apple Wallet Passes with Node.js: A Comprehensive Guide
Introduction
Digital wallet passes have become increasingly popular for loyalty programs, event tickets, and more, offering convenience for both users and businesses. After spending a couple months of development and research into creating a web service for Google and Apple Wallet using Node.js, I noticed a significant gap in available online resources on this topic. This article aims to bridge that gap by providing a comprehensive guide for developers interested in implementing similar services.
For those interested in Google Wallet implementation, I've also created a companion repository with a complete Node.js implementation: Google Wallet Passes. You can also check out the Google Wallet documentation for a detailed guide.
In this article, I will walk you through the process of creating and updating passes for Apple Wallet, including detailed implementation guides, code examples, and best practices for pass distribution and automatic updates.
Apple Wallet Integration
The Apple Wallet integration differs significantly from Google's approach. While Google provides a dedicated API for pass creation and updates, Apple's implementation requires you to:
- Create passes on your server
- Maintain a database of registered devices
- Integrate with Apple Push Notification Service (APNs) for pass updates
Prerequisites
Before implementing the pass system, you'll need to set up the necessary certificates.
Certificate Setup
For detailed certificate creation instructions, I recommend following these resources:
- passkit-generator wiki for certificate generation
- YouTube tutorial on pass creation (focus on the certificate setup section)
Required Dependencies
Add the following dependencies to your project:
{
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"file-system": "^2.2.2",
"get-image-colors": "^4.0.1",
"jsonwebtoken": "^9.0.2",
"passkit-generator": "^3.2.0"
}
Pass Creation Process
The passkit-generator library allows you to create passes either from a JSON file or programmatically. I've implemented two models for different pass types, which can be customized based on your requirements.
Key configuration parameters:
- passTypeIdentifier: Your Apple-registered pass type identifier (must match your distribution certificate)
- teamIdentifier: Your Apple Developer Program Team ID
Important: Store your certificates securely on your server for pass signing.
Example pass model:
// models/Custom.pass/pass.json
{
"formatVersion" : 1,
"passTypeIdentifier" : "pass.com.example.app",
"teamIdentifier" : "1ABC123456",
"generic" : {
"primaryFields" : [],
"secondaryFields" : [],
"auxiliaryFields" : [],
"backFields" : []
}
}
Example store card model:
// models/StoreCard.pass/pass.json
{
"formatVersion" : 1,
"passTypeIdentifier" : "pass.com.example.app",
"teamIdentifier" : "1ABC123456",
"storeCard" : {
"primaryFields" : [],
"secondaryFields" : [],
"auxiliaryFields" : [],
"backFields" : []
}
}
Implementation Guide
I've defined types for pass properties. In this example, I'll demonstrate a StampPass implementation, which includes image support for a loyalty program. You can modify these properties based on your specific requirements.
StampPass Properties
/**
* @typedef {object} StampPassProperties
* @property {string} programName
* @property {string} organizationName
* @property {string} logoUri
* @property {string} qrCodeLink
* @property {string} accountId
* @property {string} fullName
* @property {string} authenticationToken
* @property {string?} stampImageUri
*/
StampPass Class
class StampPass {
constructor() {
/**
* The certificates used to sign the pass. Environment
* variable: PASS_PHRASE.
*/
this.certificates = {
wwdr: fs.readFileSync("./.certificates/wwdr.pem"),
signerCert: fs.readFileSync("./.certificates/signerCert.pem"),
signerKey: fs.readFileSync("./.certificates/signerKey.pem"),
signerKeyPassphrase: process.env.PASS_PHRASE,
}
}
}
Create Pass Method
/**
* Create a pass.
*
* @param {string} serialNumber Developer-defined unique ID for this pass.
* @param {StampPassProperties} stampPassProperties The properties for the pass to create.
*
* @returns {Promise}
*/
async createPass(serialNumber, stampPassProperties) {
// First we get the image from the URL
const logoResp = await axios.get(
stampPassProperties.logoUri,
{ responseType: "arraybuffer" });
// Then we convert the image to a buffer
const buffer = Buffer.from(logoResp.data, "uft-8");
const stampImageResp = await axios.get(
stampPassProperties.stampImageUri,
{ responseType: "arraybuffer" });
const stampBuffer = Buffer.from(stampImageResp.data, "uft-8");
// We create the pass from the model
const pass = await PKPass.from(
{
model: "./models/StoreCard.pass",
certificates: {
wwdr: this.certificates.wwdr,
signerCert: this.certificates.signerCert,
signerKey: this.certificates.signerKey,
signerKeyPassphrase: this.certificates.signerKeyPassphrase,
},
},
{
serialNumber,
authenticationToken: stampPassProperties.authenticationToken,
webServiceURL: process.env.WEB_SERVICE_URL,
organizationName: stampPassProperties.organizationName,
description: stampPassProperties.organizationName,
logoText: stampPassProperties.programName,
}
);
// We add the images to the pass
pass.addBuffer("logo.png", buffer);
pass.addBuffer("logo@2x.png", buffer);
pass.addBuffer("icon@.png", buffer);
pass.addBuffer("icon@2x.png", buffer);
pass.addBuffer("strip.png", stampBuffer);
pass.addBuffer("strip@2x.png", stampBuffer);
// We set the barcode and fields of the pass
pass.setBarcodes({
"message": stampPassProperties.qrCodeLink,
"format": "PKBarcodeFormatQR",
"messageEncoding": "iso-8859-1",
"altText": stampPassProperties.accountId,
});
// We set the fields of the pass
pass.secondaryFields.push({
"key": "fullName",
"label": "Name",
"value": stampPassProperties.fullName,
});
pass.secondaryFields.push({
"key": "accountId",
"label": "",
"value": stampPassProperties.accountId,
"textAlignment": "PKTextAlignmentRight"
});
const bufferPass = pass.getAsBuffer();
// We can save the pass to the file system
fs.writeFileSync("YourPassName.pkpass", bufferPass);
}
Usage Example
Create a main.js file to test the implementation:
require('dotenv').config();
const { LoyaltyPass } = require('./loyaltyPass');
const { StampPass } = require('./stampPass');
const PassTypeEnum = {
LOYALTY: 'loyalty',
STAMP: 'stamp'
};
Pass Distribution
Once you've created the pass file, you have two main options for distribution:
- Email the .pkpass file as an attachment
- Host the file online and share the download link
For production environments, I recommend using a service like Vercel Blob for file hosting. When users access the link on their iOS devices, they'll be prompted to add the pass to their Apple Wallet.
Setting up Automatic Updates
Apple Wallet passes can be automatically updated to reflect changes in loyalty points, stamps, or other information. This requires setting up a web service that communicates with Apple's Push Notification Service (APNs). When a pass is added to Apple Wallet, the device registers with your web service, enabling you to push updates.
Key components needed:
- A web service endpoint to handle device registration/unregistration
- Apple Push Notification Service (APNS) integration
- Secure storage for device tokens and pass information
- Logic to determine when passes need updating
The web service should implement the Apples Server Configuration to handle device registration and pass updates properly.
Pass Update Mechanism
To implement automatic pass updates, you need to set up a complete web service that handles device registration, pass updates, and push notifications. Here's a comprehensive guide to implementing this system:
1. Server Configuration
When a user adds your pass to their Apple Wallet, their device automatically communicates with your web service. This communication is essential for enabling automatic updates. The device registration process relies on three key pieces of information that you provide when creating the pass:
- serialNumber: A unique identifier for each pass
- authenticationToken: A security token used to authenticate API requests
- webServiceURL: The base URL of your server's API endpoints
These parameters are configured when creating the pass using the PKPass.from() method:
async createPass(serialNumber, stampPassProperties) {
...
const pass = await PKPass.from(
...
{
serialNumber,
authenticationToken: stampPassProperties.authenticationToken,
webServiceURL: process.env.WEB_SERVICE_URL,
...
}
);
...
}
Once these parameters are set, Apple Wallet will automatically attempt to register the device with your web service when the user adds the pass. Your server should implement the necessary endpoints to handle this registration process securely.
The endpoint that the device will call to register is:
POST https://yourpasshost/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}
Your server should authenticate every request using the authenticationToken provided in the Authorization header:
ApplePass {authenticationToken}
2. Database Structure
Apple recommends using a relational database to store device and pass information. Here's the recommended schema:
Device Table
Stores information about devices that contain updatable passes.
Attribute | Type | Description |
---|---|---|
id | int |
Primary key |
deviceLibraryIdentifier | string |
Unique ID to identify and authenticate a device |
pushToken | string |
Push token used to send update notifications to the device |
Pass Table
Stores information about updatable passes.
Attribute | Type | Description |
---|---|---|
id | int |
Primary key |
passTypeIdentifier | string |
Your pass type identifier |
serialNumber | string |
Unique identifier for the pass |
lastUpdateTag | date |
Timestamp of the last pass update |
Registration Table
Manages the many-to-many relationship between devices and passes.
Attribute | Type | Description |
---|---|---|
id | int |
Primary key |
deviceId | int |
Foreign key referencing the Device table |
passId | int |
Foreign key referencing the Pass table |
3. Device Registration Process
When a device attempts to register, your server should:
- Authenticate the request using the provided
authenticationToken
- Find or create the device record using the
deviceLibraryIdentifier
andpushToken
- Find or create the pass record using the
passTypeIdentifier
andserialNumber
- Find or create the registration relationship between the device and pass
- Return appropriate status codes:
200
: Pass already registered201
: Registration successful401
: Unauthorized request
Here's an example implementation using Next.js:
export const POST = async (request: NextRequest, { params }: { params: PathParams }): Promise => {
const { deviceLibraryIdentifier, passTypeIdentifier, serialNumber } = params;
const { pushToken } = await request.json();
const authHeader = request.headers.get("Authorization");
if (authHeader !== `ApplePass ${process.env.AUTH_TOKEN}`) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
try {
// Find or create device
const device = await Devices.findOrCreate({
deviceLibraryIdentifier,
pushToken
});
// Find or create pass
const pass = await Passes.findOrCreate({
passTypeIdentifier,
serialNumber
});
// Create registration if it doesn't exist
const registration = await Registrations.findOrCreate({
deviceId: device.id,
passId: pass.id
});
return NextResponse.json(
{ message: registration.isNew ? "Registration created" : "Registration exists" },
{ status: registration.isNew ? 201 : 200 }
);
} catch (error) {
return NextResponse.json(
{ message: "Internal Server Error" },
{ status: 500 }
);
}
};
4. Pass Update Process
To update passes, implement the following endpoint:
GET https://yourpasshost/v1/passes/{passTypeIdentifier}/{serialNumber}
This endpoint should:
- Authenticate the request
- Generate or retrieve the updated pass
- You have two options for handling pass updates:
- Generate the updated pass directly in this endpoint
- Generate the pass elsewhere in your application and store it (e.g., in a cloud storage service)
- The example below demonstrates the second approach, where we retrieve a pre-generated pass from storage
- You have two options for handling pass updates:
- Return the pass file with appropriate headers
Example implementation:
export const GET = async (request: NextRequest, { params }: { params: PathParams }): Promise => {
const { serialNumber } = params;
const authHeader = request.headers.get("Authorization");
if (authHeader !== `ApplePass ${process.env.AUTH_TOKEN}`) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
try {
// Retrieve the pass record from the database
const pass = await Passes.find(serialNumber);
// Fetch the pre-generated pass file from storage
// In this example, we assume the pass is stored at a URL
const passBuffer = await fetch(pass.url).then(res => res.arrayBuffer());
// Convert the downloaded ArrayBuffer to a Buffer and send it to the client
return new Response(Buffer.from(passBuffer), {
status: 200,
headers: {
"Content-Type": "application/vnd.apple.pkpass",
"Content-Disposition": `attachment; filename="${serialNumber}.pkpass"`
}
});
} catch (error) {
return NextResponse.json(
{ message: "Internal Server Error" },
{ status: 500 }
);
}
};
5. Device Unregistration
When a user removes a pass from their device, implement this endpoint:
DELETE https://yourpasshost/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}
This endpoint should:
- Authenticate the request
- Remove the registration
- Delete the device records if the registration is not found
- Return appropriate status codes
Example implementation:
export const DELETE = async (request: NextRequest, { params }: { params: PathParams }): Promise => {
const { deviceLibraryIdentifier, serialNumber } = params;
const authHeader = request.headers.get("Authorization");
if (authHeader !== `ApplePass ${process.env.AUTH_TOKEN}`) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
try {
// First try to find and delete the registration
try {
const registration = await Registrations.find(deviceLibraryIdentifier, serialNumber);
await Registrations.delete(registration.id);
return NextResponse.json({ message: "Registration deleted successfully" }, { status: 200 });
} catch (error) {
if (error instanceof RecordNotFoundError) {
// If registration not found, try to delete the device
try {
const device = await Devices.find(deviceLibraryIdentifier);
await Devices.delete(device.id);
return NextResponse.json({ message: "Device deleted successfully" }, { status: 200 });
} catch (deviceError) {
if (deviceError instanceof RecordNotFoundError) {
return NextResponse.json({ message: "No registration or device found" }, { status: 200 });
}
throw deviceError;
}
}
throw error;
}
} catch (error) {
return NextResponse.json(
{ message: "Internal Server Error" },
{ status: 500 }
);
}
};
6. Automatic Pass Updates
By default, users need to manually request pass updates by performing a pull-down gesture on their iOS devices. To enable automatic updates, you need to implement push notifications using Apple's Push Notification Service (APNs). This process involves:
- Sending a push notification to the user's device via APNs
- The device then requests the list of updatable passes from your server
- The device request each pass update
- Your server provides the updated version for each pass
To implement this, you'll need to establish a connection with APNs using either certificate-based or token-based authentication. This guide demonstrates the token-based approach, which is recommended for modern applications.
6.1 Establishing APNs Connection
To establish a token-based connection with APNs, you need to create a private key in your Apple Developer account:
- Go to Certificates, Identifiers & Profiles
- Click Keys in the sidebar
- Click the add button (+) in the top left
- Enter a unique name for the key
- Select the checkbox next to "Apple Push Notification service"
- Click Continue
- Configure APNs:
- Click Configure next to "Apple Push Notification service"
- Choose the environment (Development or Production)
- Select the key type (Team Scoped or Topic Specific)
- For Topic Specific keys, select the associated topics
- Review and confirm the configuration
- Download the key file (
.p8
extension)
Important: Save both the Key ID (10-character string) and the .p8
file securely. You won't be able to download the key again, and you'll need both for authentication.
6.2 Creating and Encrypting JSON Token
When sending requests to APNs, you need to sign your token data as a JWT using your private key. The token must include:
Key | Value | Description |
---|---|---|
alg |
ES256 |
The encryption algorithm (APNs only supports ES256) |
kid |
string |
Your 10-character Key ID from Apple Developer account |
iss |
string |
Your 10-character Team ID used for pass creation |
iat |
number |
Unix timestamp (seconds) when the token was generated |
The token is divided into:
- Header: Contains
alg
andkid
- Claims: Contains
iss
andiat
Note: APNs will reject tokens older than one hour, returning an ExpiredProviderToken (403) error.
After encrypting your resulting JSON you attach the token to your notification request header:
authorization = bearer eyAia2lkIjogIjhZTDNHM1JSWDciIH0.eyAiaXNzIjogIkM4Nk5WOUpYM0QiLCAiaWF0IjogIjE0NTkxNDM1ODA2NTAiIH0.MEYCIQDzqyahmH1rz1s-LFNkylXEa2lZ_aOCX4daxxTZkVEGzwIhALvkClnx5m5eAT6Lxw7LZtEQcH6JENhJTMArwLf3sXwi
6.3 Sending Push Notifications
When sending notifications to APNs, you need to include specific headers:
Header field | Required | Description |
---|---|---|
:method |
Yes | Must be POST |
:path |
Yes | /3/device/<device_token> |
authorization |
Yes | bearer <provider_token> |
apns-push-type |
Yes* | Must match notification payload |
apns-id |
No | UUID for notification tracking |
apns-expiration |
No | Unix timestamp for notification validity |
apns-priority |
No | Notification priority (1, 5, or 10) |
apns-topic |
Yes | For pass notifications, this must be your passTypeIdentifier (e.g., pass.com.example.app ) |
apns-collapse-id |
No | ID for merging multiple notifications |
*Required for watchOS 6+, recommended for other platforms
Here's an example implementation:
const fs = require("fs");
const http2 = require("http2");
const jwt = require("jsonwebtoken");
async function sendApplePushNotification(deviceToken, payload = {}) { // Empty payload, as necessary (see apple documentation)
// Create JWT token
const tokenData = {
header: {
alg: "ES256",
kid: process.env.APPLE_KEY_ID,
},
payload: {
iss: process.env.APPLE_TEAM_ID,
iat: Math.floor(Date.now() / 1000), // EPOCH time
},
};
// Load certificates and private key
const privateKey = fs.readFileSync("./.certificates/AuthKey_ABC123DEF1.p8");
const options = {
cert: fs.readFileSync("./.certificates/signerCert.pem"),
key: fs.readFileSync("./.certificates/signerKey.pem"),
ca: fs.readFileSync("./.certificates/wwdr.pem"),
passphrase: process.env.PASS_PHRASE,
};
// Sign the token
const encodedToken = jwt.sign(tokenData.payload, privateKey, {
algorithm: "ES256",
header: tokenData.header,
});
// Set up request headers
const headers = {
":method": "POST",
":path": `/3/device/${deviceToken}`,
authorization: `bearer ${encodedToken}`,
"apns-push-type": "background",
"apns-expiration": "0",
"apns-priority": "10",
"apns-topic": process.env.PASS_TYPE_IDENTIFIER, // Must match the passTypeIdentifier used when creating the pass
"Content-Type": "application/json",
};
// Create HTTP/2 client
const client = http2.connect("https://api.push.apple.com");
// Handle connection errors
client.on("error", (error) => {
console.error("Client connection error:", error);
});
// Wait for connection
const connectPromise = new Promise((resolve, reject) => {
client.on("connect", resolve);
client.on("error", reject);
});
await connectPromise;
// Send request
const req = client.request(headers, options);
req.setEncoding("utf8");
req.write(JSON.stringify(payload));
req.end();
// Handle response
const responsePromise = new Promise((resolve, reject) => {
req.on("response", resolve);
req.on("data", () => {});
req.on("end", () => client.close());
req.on("error", (error) => {
console.error("Request error:", error);
reject(error);
});
// Set timeout
req.setTimeout(10000, () => {
console.error("Request timed out");
req.close();
reject(new Error("Request timed out"));
});
// Handle socket events
req.on("socket", (socket) => {
socket.on("timeout", () => {
console.error("Socket timeout");
req.close();
reject(new Error("Socket timeout"));
});
socket.on("error", (error) => {
console.error("Socket error:", error);
reject(error);
});
socket.on("close", () => {});
});
});
await responsePromise;
client.on("close", () => {});
}
6.4 Getting List of Updatable Passes
After APNs delivers the push notification, the device will request a list of passes that need updating. Implement this endpoint:
GET https://yourpasshost.example.com/v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}?passesUpdatedSince={previousLastUpdated}
The endpoint should:
- Authenticate the request
- Find passes registered for the device
- Filter passes updated since
previousLastUpdated
- Return an array of serialNumbers and the most recent lastUpdated timestamp
- Return 204 if no updates are available
Example implementation:
export const GET = async (request: NextRequest, { params }: { params: PathParams }): Promise => {
const { deviceLibraryIdentifier, passTypeIdentifier } = params;
const authHeader = request.headers.get("Authorization");
const passesUpdatedSince = request.nextUrl.searchParams.get("passesUpdatedSince");
if (authHeader !== `ApplePass ${process.env.AUTH_TOKEN}`) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
try {
// Find passes for the device
const passes = await Passes.find(deviceLibraryIdentifier, passTypeIdentifier);
// Get serial numbers and latest update time
const serialNumbers = passes.map(pass => pass.serialNumber);
const lastUpdated = Math.max(...passes.map(pass =>
new Date(pass.lastUpdateTag).getTime() / 1000
));
// Check if updates are needed
if (passesUpdatedSince) {
const previousUpdate = new Date(passesUpdatedSince).getTime() / 1000;
if (lastUpdated <= previousUpdate) {
return new NextResponse(null, { status: 204 });
}
}
// Return updated passes
return NextResponse.json({
serialNumbers,
lastUpdated: lastUpdated.toString()
}, { status: 200 });
} catch (error) {
return NextResponse.json(
{ message: "Internal Server Error" },
{ status: 500 }
);
}
};
Conclusion
Building a web service for Apple Wallet passes is a complex but rewarding endeavor. While the initial setup requires careful attention to certificates, endpoints, and push notifications, the end result provides a seamless experience for your users.
The most challenging aspects of this implementation are:
- Managing device registrations and updates
- Handling push notifications reliably
- Maintaining security across all endpoints
However, once implemented, you'll have a robust system that can handle various types of passes, from loyalty cards to event tickets, with real-time updates and automatic synchronization.
Remember to stay updated with Apple's documentation and test your implementation regularly across different iOS versions. The effort you put into building this system will pay off in the form of enhanced user engagement and satisfaction.
Note: This article is based on the author's experience and research. For the most up-to-date information, always refer to the official Apple documentation.