id and its permanent link on your customer record, and send the customer to that link. Your own data answers whether this customer already has a pass.
Issue once, with context. Create new pass carries programId, walletType (APPLE_WALLET or GOOGLE_WALLET), your externalId, customer details for personalization, and the customer’s current loyaltyState, such as pointsBalance and currentTierId. The pass is correct the moment it lands in the wallet. An optional payload sets what the pass carries for in-store scans; see In-store. Returns 201 with the pass object — store its id and its link.
One permanent link. Every pass response carries link: a public URL that never expires and stays the same for the life of the pass. It is safe to store, to email, and to print as a QR code. There is no separate call to make — the link is in the create response, and in every later pass response from Get pass details, List passes, and Update pass.
A returning customer skips the create. If you already hold a link, send the customer straight to it. That covers the customer who taps the button twice, reinstalls the wallet app, or moves to a new phone: same pass, same link. The link works for passes in PREACTIVE, ACTIVE, and INACTIVE, so a customer who deleted the pass can re-add the one they already have. A voided or expired pass still carries a link, but opening it shows This pass is no longer available.
Activation is confirmed for you. When the customer adds the pass, it flips from PREACTIVE to ACTIVE and a signed PASS_STATUS_CHANGED webhook delivery tells your backend. Write it back to your customer record from there.
Two ways to open the link
As-is: the hosted pass page. Opened without parameters, the link shows a page headed Your pass is ready with an Add to Apple Wallet or Add to Google Wallet button, matching the wallet type the pass was issued for. On a phone that can’t hold the pass — an Android phone opening an Apple Wallet pass, or the reverse — the page says so and names the right device. On a computer it adds an On a phone? section with the link ready to copy, so the customer can open it on their phone; there is no QR code. Use the page whenever you don’t control the device the link will be opened on: email, SMS, print. With?delivery=direct: straight to the wallet. Append ?delivery=direct and the link answers with a 302 to the wallet artifact itself — the .pkpass file for an Apple Wallet pass, the save link for a Google Wallet pass. No page, no device detection. Use it behind your own button when you already know the device, such as an Add to Apple Wallet button in your iOS app. The redirect only happens for a pass that is ready to be added: issued for Apple Wallet or Google Wallet, in PREACTIVE, ACTIVE, or INACTIVE. In every other case — a voided or expired pass, a wallet type the link can’t serve, an invalid link, a temporary failure — the response is the hosted pass page with a 200, not an error status. Don’t build a client that assumes a 302.
Device handling in direct mode is yours. Show the right button for the device (there is no Google Wallet app on iOS, and Apple Wallet passes can’t open on Android), and mind the desktop case: the Apple artifact is a .pkpass file that only Safari on macOS opens in Wallet — in any other desktop browser it lands in the downloads folder as a dead end. Stell’s hosted experiences (enrollment flow and wallet button widget) solve this by showing the link as a QR code the customer scans with their iPhone. If you build your own flow, bring your own equivalent, reserve the Apple button for devices that can use it, or drop the delivery parameter and let the hosted pass page handle the desktop visitor. The Google save link opens in any browser.
The older Generate Apple Wallet installation link and Generate Google Wallet save link endpoints return the same short-lived URLs. They are deprecated in favor of link but keep working, with no removal date set. Don’t use them in a new integration.
One pass per customer
The API does not deduplicate.POST /v1/passes creates a new pass every time it is called. There is no uniqueness constraint on externalId and no idempotency key — two calls for the same customer produce two passes with two different ids. Both are real and both scan, but only one of them receives the updates you send, so the customer’s points can silently stop moving on the pass in their wallet. Keeping one pass per customer is your backend’s job.
Store the pass id and guard the create. One id on the customer record, written the moment the pass is issued, is the whole mechanism. Every later step — sending the link, updating points, voiding — addresses that id. Guard the create against concurrent clicks the same way you would any other insert: a unique constraint or a lock on your side of the customer record, not a retry against Stell.
“One pass” means one per program, per wallet type. A customer enrolled in two programs holds two passes. A customer who wants the pass on both an iPhone and an Android device also holds two, because a pass is bound to the walletType it was issued with. Store the id and link per wallet type if you support both, and keep their loyaltyState in sync by updating each one.
Reissue rather than duplicate. When a customer loses access, don’t issue a second pass. Send them the existing pass’s link again — it hasn’t changed. Issue a replacement only after you void the old pass, so exactly one live pass per customer remains.
If you don’t store the pass id
You can run the flow without keeping theid and link, at the cost of a lookup on every click. This is the fallback, not the default — the lookup is a read of Stell’s data to reconstruct something your own record could have told you.
Filter the lookup. List passes on programId + externalId returns matches in every status, voided and expired included. Filter on status and walletType, or check the isVoided and isExpired flags on each result, before deciding a pass is reusable. Treating an unfiltered result as “no pass exists” is how duplicates get created; treating a voided pass as reusable is how customers end up on This pass is no longer available. Each result carries its link, so once you have the reusable pass you redirect without a further call.
Paginate to the end. A response page can come back empty and still carry a nextToken. Follow the token until it is absent before concluding the customer has no pass.
Don’t want to build this flow? The wallet button
widget does the same thing with no API
code. Stell hosts the button, the enrollment page, and the pass issuing.