> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getstell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Add to Apple Wallet and Google Wallet from your own site or app

> Issue a pass from your backend and send the customer to one permanent link that opens the right wallet, from a page, app screen, email or QR code.

This flow puts a pass in the customer's wallet from your own touchpoint: a page, app screen, email, or printed QR code. Your backend orchestrates it, and the API key never leaves your server.

The simplest version, and the one to build: issue the pass once, store Stell's pass `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.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant C as Customer
    participant B as Your backend
    participant S as Stell API
    C->>B: Taps Add to Wallet
    Note over B: No pass id on the customer record
    B->>S: POST /v1/passes
    S-->>B: 201 with pass id and link (PREACTIVE)
    Note over B: Store the pass id and link on the customer record
    B-->>C: Redirect to link, pass lands in the wallet
    S--)B: Webhook PASS_STATUS_CHANGED (PREACTIVE → ACTIVE)
```

**Issue once, with context.** [Create new pass](/api-reference/passes/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](/api-reference/integration/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](/api-reference/passes/get-pass-details), [List passes](/api-reference/passes/list-passes), and [Update pass](/api-reference/passes/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](/api-reference/webhooks/deliveries) 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](/enrollment/adding-to-wallet) and [wallet button widget](/enrollment/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](/api-reference/passes/generate-apple-wallet-installation-link) and [Generate Google Wallet save link](/api-reference/passes/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, so two calls for the same customer produce two passes with two different `id`s. 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, since it hasn't changed. Issue a replacement only after you [void](/api-reference/passes/void-pass) the old pass, so exactly one live pass per customer remains.

<Tip>
  Issuing passes for an existing member base? [Batch create
  passes](/api-reference/passes/batch-create-passes) takes up to 1000 passes
  in one call and returns a `link` for each pass it creates, ready to drop
  into a mailing.
</Tip>

## If you don't store the pass id

You can run the flow without keeping the `id` 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.

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant C as Customer
    participant B as Your backend
    participant S as Stell API
    C->>B: Taps Add to Wallet
    B->>S: GET /v1/passes?programId=…&externalId=…&status=ACTIVE
    S-->>B: Existing pass with its link, or none
    opt No existing pass
        B->>S: POST /v1/passes
        S-->>B: 201 with pass id and link (PREACTIVE)
    end
    B-->>C: Redirect to link, pass lands in the wallet
```

**Filter the lookup.** [List passes](/api-reference/passes/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.

<Note>
  Don't want to build this flow? The [wallet button
  widget](/enrollment/wallet-button-widget) does the same thing with no API
  code. Stell hosts the button, the enrollment page, and the pass issuing.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.