npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@erc8426/apple

v0.4.0

Published

Apple Wallet delivery for ERC-8426: pkpass building, the PassKit web service, APNs push

Readme

@erc8426/apple

Apple Wallet delivery for ERC-8426 wallet passes: signed .pkpass building, the PassKit web service that keeps installed passes current, APNs pushes, and a format provider that serves the apple entry of a pass manifest.

The standard leaves generation, signing and push out of scope. This package fills that gap with the patterns of two production deployments, so an issuer describes a pass once as a PassContent (from @erc8426/core) and gets a working Apple pass.

What you need from Apple

  1. An Apple Developer Program membership and your Team ID (Membership details page).

  2. A Pass Type ID, for example pass.com.example.collection, under Certificates, Identifiers and Profiles.

  3. A Pass Type ID certificate. Create it for that identifier, download the .cer, open it in Keychain Access and export certificate plus private key as a .p12. Convert it to PEM:

    # OpenSSL 3 needs -legacy for most Keychain exports.
    openssl pkcs12 -legacy -in pass.p12 -clcerts -nokeys -out signerCert.pem
    openssl pkcs12 -legacy -in pass.p12 -nocerts -out signerKey.pem

    The second command asks for a PEM pass phrase; pass the same value as signerKeyPassphrase. Add -nodes instead to write an unencrypted key and keep it in a secret manager.

  4. The Apple WWDR intermediate, G4. Pass Type ID certificates issued today chain to G4, and a bundle signed against the wrong intermediate installs nowhere and reports nothing.

    curl -O https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer
    openssl x509 -inform der -in AppleWWDRCAG4.cer -out wwdr.pem
  5. Push credentials (optional, for live updates). The Pass Type ID certificate itself is the APNs client credential for pass updates, and certificate auth is the path both production deployments run. Token auth with an APNs .p8 key (Keys page, "Apple Push Notifications service") is also supported; confirm it against your pass type topic before relying on it, because Apple has historically accepted only certificates for Wallet topics.

Never commit any of these. Load them from a secret store at startup.

The web service URL

A pass updates only if it carries a webServiceURL, and Apple only calls one that is https on a public host with a certificate that chains to a public root. On an http origin (local development) the provider omits the web service, so passes still install but never update. Pass pushes always travel through the production APNs gateway, development passes included.

Two modes

With @erc8426/issuer (recommended). The provider implements core's PassFileProvider. The issuer calls apple.passFile(ctx), receives the signed .pkpass (contentType: application/vnd.apple.pkpass, filename: pass.pkpass), and serves it at its own rotating 256-bit capability URL, so download hosting and rotation live in one place. Omit linkSecret, and mount only apple.webService at basePath so devices can register and fetch updates. notifyUpdate pushes through APNs as usual. A new owner seen by passFile still rotates the pass's device token and pushes the previous holder their superseded pass.

Standalone. Without the issuer, pass a linkSecret and mount apple.handle, which serves both the web service and the download route. apple.acquisitionUrl(ctx) then returns a capability URL on your origin.

Wiring (standalone)

The handlers are Fetch API functions, (Request) => Promise<Response>, so they mount in Next.js route handlers, Hono, Bun, or Node's http behind a small adapter.

import { appleFormatProvider, createApnsClient, MemoryApplePassStore } from "@erc8426/apple";

const store = new MemoryApplePassStore(); // replace with a persistent ApplePassStore

const certificates = {
  signerCert: process.env.PASS_SIGNER_CERT_PEM!,
  signerKey: process.env.PASS_SIGNER_KEY_PEM!,
  signerKeyPassphrase: process.env.PASS_SIGNER_KEY_PASSPHRASE,
  wwdr: process.env.APPLE_WWDR_G4_PEM!,
};

const apns = createApnsClient({
  certificate: { cert: certificates.signerCert, key: certificates.signerKey, passphrase: certificates.signerKeyPassphrase },
  passTypeIdentifier: "pass.com.example.collection",
  store,
});

export const apple = appleFormatProvider({
  passTypeIdentifier: "pass.com.example.collection",
  teamIdentifier: "ABCDE12345",
  certificates,
  origin: "https://passes.example.com",
  basePath: "/apple",
  linkSecret: process.env.PASS_LINK_SECRET!, // 32+ random bytes, never stored with the passes
  store,
  apns,
  images: { icon: { url: "https://passes.example.com/icon.png" }, logo: { url: "https://passes.example.com/logo.png" } },
});

// Next.js: app/apple/[...path]/route.ts
export const GET = (req: Request) => apple.handle(req);
export const POST = GET;
export const DELETE = GET;

When the issuer resolves a manifest it calls apple.acquisitionUrl({ token, owner, content }) and puts the result under formats.apple. When content changes (a PassUpdate event, a balance move) it calls apple.notifyUpdate(...), which stores the new content, bumps Last-Modified, and pushes every registered device. For an owner's request to reset their links without the issuer, call apple.rotate(serial, freshContent): every existing copy, a leaked one included, refreshes into the superseded card and can no longer register devices, and the owner's new download URL carries the fresh content. (With the issuer, rotation mints a new serial and voids the old one; voided content retires the old serial's token the same way.)

Field limits

Apple drops overflow fields silently, so toPassJson refuses them instead, the way it refuses a duplicate key: up to three header fields on every style; on generic and storeCard one primary field and at most four secondary and auxiliary fields combined; on posterGeneric one header field, up to four primary fields and one footer field (WWDC26 session 209 shows a single footer field and says further ones are not displayed). eventTicket and coupon lay their rows out differently, and a posterGeneric pass's legacy dictionary carries the poster's fields as Apple's sample does; neither is capped here.

Featured Actions (iOS 27)

iOS 27 draws up to two featured actions beneath the pass face, as cards with an icon and Apple's own localized call to action for the action type, in the order given. Set featuredActions on the pass content; each entry is an absolute https url, or link: <key> naming one of the content's links, whose capability URL is filled in at render time and so rotates with the pass. Older iOS ignores the key; Google has no equivalent.

render: ({ serial, links }) => ({
  ...content,
  links: [{ key: "feed", label: "Feed", url: links.feed }],
  featuredActions: [
    { id: "feed", type: "addToBalance", link: "feed" },
    { id: "offers", type: "membershipBenefits", url: "https://issuer.example/offers" },
  ],
}),

Types: membershipBenefits, viewOffersRewards, place, call, order, shop, addToBalance, bookAppointment, bookCar, bookFlight, bookStay, viewSchedule, watchTrailer, listenToMusic (FeaturedActionType, FEATURED_ACTION_TYPES). The list is closed: the signing library validates against it and an unknown type fails at signing with an opaque "type is missing", so toPassJson refuses it by name first. place is different: it opens Apple Maps at a place and takes placeIdentifier (an Apple Maps place id) instead of a url or link. The call to action is Apple's wording for the type, not yours, so pick the type that names what the holder gets. More than two, a duplicate id, a non-https or scheme-less URL, or a link the content does not carry is refused at render time. A superseded pass drops every featured action that points at a link, and supersededContent carries none.

Security note: a featured action sits on the front of the pass and in every screenshot of it, where a back-field link does not. The spec's on-screen Security Consideration applies to it in full: a capability link shown as a featured action is exactly as forwardable as the pass face, so it should reach only an action with a documented bound, and rotation on the owner's request stays the remedy.

Security properties

  • Per-pass authentication tokens. Every serial gets its own random 256-bit token, compared in constant time. A leaked token exposes one pass, not the collection.
  • Capability download URLs. The acquisition URL is <origin><basePath>/passes/<serial>/<capability>.pkpass, where the capability is an HMAC of the pass's current token under linkSecret. It is not derivable from the serial, token id or any public data, it needs no table of its own, and a read-only dump of the store alone does not yield working URLs. Unknown serials and wrong capabilities both answer 404.
  • Rotation on transfer. When a different owner acquires the pass, its token rotates: the previous holder's download URL stops resolving, and their installed pass, on its next refresh, receives a superseded rendering (voided, no links, no barcode, no live values) that says why: "Transferred" for a change of hands, "Links reset" with a pointer to the current pass for an owner's reset. Pass supersede(content, reason) for your own wording. A retired token may refresh and unregister its own device but can never register a new one, and the refresh embeds the retired token, never the new owner's.
  • Bounded pre-auth input. /v1/log is unauthenticated by Apple's design: its body is capped at 16 KiB while streaming (413 past it), and entries are capped in count and length and stripped of control characters before onLog sees them. Registration bodies are capped at 1 KiB.

A pass is a projection of the token, not the token. Nothing in this package treats holding a pass, or its download URL, as proof of ownership; state-changing actions reached from pass links need the spec's authorization checks, including the fresh entitlement read.

Building blocks

  • buildPkpass(content, options) signs a bundle; toPassJson is the pure mapping. Styles map to generic, eventTicket, storeCard, coupon, posterGeneric. Links become back fields with attributedValue anchors. Field keys must be unique across the whole pass; a duplicate throws, because Apple would drop it silently. Images must be PNG; URL sources are fetched at build time with a timeout and size cap, over https only, to public addresses only (every resolved address and every redirect hop is checked, so a URL from token metadata cannot reach your internal network or cloud metadata). imageFetch.allowPrivateNetwork relaxes this for local development. An icon is required.
  • posterGeneric (iOS 27 and later). The pass carries two dictionaries: posterGeneric with header (one field), primary (up to four), footer (one) and the back, and a legacy posterFallback dictionary (default generic) with the full field set, so iOS 26 and earlier render the legacy layout. Images: artwork 358x448 points (a material strip and the QR cover its bottom edge) and primaryLogo 30 points tall and 30 to 126 wide, plus the fallback style's own slots. Mind the fallback's own layout: generic has no strip, so on iOS 26 and earlier a generic fallback shows a thumbnail or no art at all; if the legacy card should keep a banner, pair the poster with storeCard or coupon and supply hero (device-checked on iOS 26.6.2: a storeCard fallback renders the strip, a generic fallback without a thumbnail renders bare). More fields than the poster face shows throws, because the device would drop them silently. Needs passkit-generator 3.6 or later (the dependency range already resolves it); 3.5 strips the posterGeneric key at signing without a word. Apple's reference: "Creating a Poster Generic Pass" in the WalletPasses documentation.
  • Signer certificate check. buildPkpass refuses a certificate whose subject UID is not the passTypeIdentifier or whose OU is not the teamIdentifier (validateSignerCertificate). Apple issues Pass Type ID certificates with exactly those attributes; a mismatched certificate signs happily and the pass installs nowhere. A certificate with no UID (a bare development certificate) is not judged.
  • applePassKitWebService({ store, passTypeIdentifier, buildPass, authenticate?, basePath?, onLog? }) implements register, unregister, updated serials, latest pass (Last-Modified and ETag; 304 on a matching If-None-Match, or on If-Modified-Since when that header is absent; passEtag is the tag) and log.
  • appleFormatProvider({ expireSuperseded: true }) gives a superseded pass an expirationDate in the past as well as voided. Off by default; the reason to turn it on is an observation on one deployment's phones, described under "A voided pass may stay in the main stack" in docs/platforms/apple.md.
  • createApnsClient(...) keeps one HTTP/2 session open and multiplexes, retires it on error, GOAWAY, a stalled stream or a quiet spell, retries tokens that never got a stream once, and removes tokens APNs answers 410 for. Every push is sent once unless you pass retry. With retry: {} a push that got no answer at all (a failed connect, a stream error or timeout) is sent again after a short delay, three sends in all by default (attempts, baseDelayMs, maxDelayMs, and maxAddedMs, which bounds how much longer a failing send can take: 25 seconds at most with the defaults), and a 403 ExpiredProviderToken is resent once on a new token. A 429 and a 5xx are never resent inside send: Apple asks for a delay after the first and 15 minutes after the second. Every PushOutcome carries attempts; a failed one carries retryable (true when a later push can land) and then retryAfterSeconds, a suggested wait. See "A lost push" in docs/platforms/apple.md. apnsRetryAdvice(outcome) is the classification on its own.
  • MemoryApplePassStore implements ApplePassStore (pass records plus device registrations) for tests and demos. A production store needs the same nine methods over a database, supersede() included (0.2.3): without it a forwarded copy that filled the device slots keeps them across every rotation and the owner's new pass is refused.