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

# Issue Apple and Google Wallet passes

> Issue, update, and revoke Apple Wallet and Google Wallet passes — loyalty cards, coupons, and event tickets — through the Orbit Wallet Passes API or the dashboard issuance builder.

# Wallet passes

Orbit can issue digital passes for Apple Wallet and Google Wallet: loyalty cards, coupons, and event tickets your customers save to the wallet app on their phone. Issue them through the API or from the dashboard's issuance builder, and deliver them by putting the pass's save link in any message you send — SMS, WhatsApp, or email.

Three pass types are supported today:

* **Loyalty cards** — points balance, tier, or membership number behind a scannable barcode.
* **Coupons** — an offer with a redemption barcode and an optional expiry date.
* **Event tickets** — a ticket with seat, gate, or order details.

## The pass lifecycle

Every pass moves through the same lifecycle:

1. **Issue** — `POST /wallet-passes/issue` creates the pass and returns both platform payloads (an Apple `pass.json` structure and a Google "Save to Wallet" link). New passes start `active`.
2. **Update** — `POST /wallet-passes/:id/update` patches an active pass's content (points balance, tier, coupon expiry, ticket seat). Each accepted update bumps the pass's `generation` counter, which is the signal that the holder should refresh the pass on their phone.
3. **Void** — `POST /wallet-passes/:id/void` permanently revokes an active pass. Voiding is terminal: a voided pass reports `status: "voided"` and rejects any further update or re-void with a `409 CONFLICT` error.

Request body (issue):

| Field                                                   | Type              | Description                                                                            |
| ------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------- |
| `type`                                                  | string (required) | `loyalty_card`, `coupon`, or `ticket`.                                                 |
| `contact_id`                                            | string            | Orbit contact this pass is issued to.                                                  |
| `external_id`                                           | string            | Your own correlation reference (order id, ticket id, membership id).                   |
| `title`                                                 | string (required) | Main pass heading.                                                                     |
| `subtitle`                                              | string            | Secondary line under the title.                                                        |
| `description`                                           | string            | Pass description shown on the back/details view.                                       |
| `organization_name`                                     | string            | Issuing organization; defaults to `Orbit`.                                             |
| `barcode_message`                                       | string            | Value encoded in the barcode.                                                          |
| `barcode_format`                                        | string            | `QR_CODE` (default), `PDF_417`, `AZTEC`, or `CODE_128`.                                |
| `background_color` / `foreground_color` / `label_color` | string            | `#RRGGBB` hex colors; omitted values use the platform default styling.                 |
| `fields`                                                | array             | Up to 20 label/value rows (`key`, `label`, `value`) rendered on the pass.              |
| `expires_at`                                            | ISO datetime      | When the pass stops being valid.                                                       |
| `metadata`                                              | object            | Free-form extra data stored with the pass.                                             |
| `idempotency_key`                                       | string            | Retries with the same key return the first issued pass instead of minting a duplicate. |

## Issuing a pass

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wallet-passes/issue \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "type": "loyalty_card",
    "contact_id": "ctn_9f8e7d6c5b4a",
    "title": "Aurora Coffee Rewards",
    "subtitle": "Gold tier",
    "organization_name": "Aurora Coffee",
    "barcode_message": "MEMBER-10482",
    "barcode_format": "QR_CODE",
    "background_color": "#1E3FB0",
    "fields": [
      { "key": "points", "label": "Points", "value": "1,240" },
      { "key": "member_since", "label": "Member since", "value": "2024" }
    ],
    "idempotency_key": "enroll-10482-2026-08"
  }'
```

### Response

```json theme={null}
{
  "data": {
    "id": "wps_a1b2c3d4e5f6g7h8a1b2c3d4e5f6g7h8",
    "type": "loyalty_card",
    "status": "active",
    "generation": 0,
    "platforms": {
      "apple": {
        "configured": true,
        "reason": null,
        "pass_json": { "formatVersion": 1, "serialNumber": "wps_a1b2c3..." }
      },
      "google": {
        "configured": true,
        "reason": null,
        "object": { "id": "3388000000000000.wps_a1b2c3...", "state": "ACTIVE" },
        "save_url": "https://pay.google.com/gp/v/save/eyJhbGciOi..."
      }
    }
  }
}
```

Issuing is **idempotent**: pass an `idempotency_key` (or an `Idempotency-Key` header) and a retry returns the original pass with `replayed: true` instead of minting a second one. The replayed response reflects the pass's *current* state — so a delayed retry after an update or void never reports stale content.

## Platform status

Before issuing, check which platforms are live:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/wallet-passes/platforms \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "apple": { "configured": false, "reason": "apple_wallet_credentials_not_configured" },
    "google": { "configured": true, "reason": null },
    "types": ["loyalty_card", "coupon", "ticket"],
    "barcode_formats": ["QR_CODE", "PDF_417", "AZTEC", "CODE_128"]
  }
}
```

* **`apple`** — `configured: true` when the Apple Wallet pass-type id and team id are connected. Until then `pass_json` is `null`, because there is no valid certificate identity to stamp the pass with.
* **`google`** — `configured: true` when the Google Wallet issuer id and service-account credentials are connected. Until then `save_url` is `null`.
* **`types` / `barcode_formats`** — the enums the issuance form can offer; use these to drive your own UI validation.

You can issue and manage passes with no platform connected — the pass content is stored and readable — but the downloadable payloads appear only after the matching platform is connected. Every read rebuilds the platform payloads fresh, so existing passes start producing working save links the moment credentials are connected; you never need to re-issue.

## Signed issuance links

Each platform has its own signing mechanism:

* **Apple Wallet** builds the pass as a signed `.pkpass` bundle: a PKCS#7 signature over a manifest of the pass files, using the Apple-issued pass signing certificate tied to your pass type. Orbit generates the unsigned `pass.json` content (`platforms.apple.pass_json`); the final binary signing step runs on the transport that holds the certificate material, once the Apple credentials are connected.
* **Google Wallet** builds a signed "Save to Google Wallet" link (`platforms.google.save_url`): the pass is wrapped in a Google *generic object* and encoded as an RS256-signed token appended to `https://pay.google.com/gp/v/save/...`. Opening that link on an Android device or in a browser adds the pass to the holder's Google Wallet account.

## Listing, updating, and voiding passes

List issued passes (newest first) with optional filters:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/wallet-passes?type=loyalty_card&status=active&limit=50" \
  -H "X-API-Key: dv_live_sk_..."
```

Update an active pass's content — only the fields you send change; everything else carries forward:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wallet-passes/wps_.../update \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "subtitle": "Platinum tier",
    "fields": [
      { "key": "points", "label": "Points", "value": "2,015" }
    ]
  }'
```

Each update increments `generation`. To clear a field, send it as explicit `null`.

Void a pass (optionally with a reason):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/wallet-passes/wps_.../void \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Membership cancelled" }'
```

A voided pass keeps its content for audit, reports `status: "voided"`, renders as expired in the wallet, and cannot be updated.

## Example: enroll a loyalty pass from an SMS deep link

The most common onboarding flow: a customer joins your loyalty program, and you text them their wallet pass.

1. **Issue the pass** tied to the enrolling contact:

   ```bash theme={null}
   curl -X POST https://api.orbit.devotel.io/api/v1/wallet-passes/issue \
     -H "X-API-Key: dv_live_sk_..." \
     -H "Content-Type: application/json" \
     -d '{
       "type": "loyalty_card",
       "contact_id": "ctn_...",
       "title": "Aurora Coffee Rewards",
       "barcode_message": "MEMBER-10482",
       "idempotency_key": "enrollment-10482"
     }'
   ```

2. **Pick the save link** from the response — `data.platforms.google.save_url` for Google Wallet holders (the Apple Wallet equivalent is the signed pass the `.pkpass` transport serves once Apple credentials are connected).

3. **Send the link over SMS** using the standard messaging endpoint:

   ```bash theme={null}
   curl -X POST https://api.orbit.devotel.io/api/v1/messages \
     -H "X-API-Key: dv_live_sk_..." \
     -H "Content-Type: application/json" \
     -d '{
       "to": "+14155552671",
       "channel": "sms",
       "content": {
         "text": "Welcome to Aurora Rewards! Add your card to your phone wallet: https://pay.google.com/gp/v/save/eyJ..."
       }
     }'
   ```

   Keep the message brand-clean — save links are long, so route them through Orbit [link shortening](/api-reference/links) if you want a short URL. The same deep-link pattern works on SMS, WhatsApp, or email; pick whichever channel the customer opted into.

## The dashboard issuance builder

Operators can run the whole lifecycle without the API. In the dashboard, open **Marketing → Wallet passes** (also reachable at **Outbound → Wallet passes**). The surface is gated to owner, admin, and developer roles.

* **Issue a pass** — the *New pass* form offers exactly the pass types and barcode formats `GET /wallet-passes/platforms` reports, and flags when a platform is not yet connected.
* **Update content** — edit points, tiers, or other fields on an active pass; each accepted edit bumps the pass generation.
* **Void a pass** — revoke through a confirmation dialog; voided passes remain visible in the list for audit.
* **Copy save links** — copy the Google save link (or read the Apple payload status) to drop into a message.

The dashboard surface and the API operate on one shared ledger, so a pass issued from the dashboard is visible to the API and vice versa.

## How issuance state is stored

Wallet-pass state lives as events in the same append-only customer-data ledger that powers loyalty programs and incentive issuance. Issuing appends an issuance event; updates and voids append overlay events; and the current state of a pass is folded from its event stream on every read. There is no separate wallet-passes table to migrate, and nothing external to keep in sync — which is also what makes idempotent replays safe: the replayed response folds to the pass's current lifecycle state.

All wallet-pass endpoints are scoped with `contacts:read` (list/get/platforms) and `contacts:write` (issue/update/void) — a pass is customer-facing collateral tied to a contact.

## Common errors

| Code               | HTTP | Cause                                                                                                 | Fix                                                               |
| ------------------ | ---- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `VALIDATION_ERROR` | 422  | A field failed validation — bad hex color, unknown `type`/`barcode_format`, or more than 20 `fields`. | Check `details` in the response envelope for the offending field. |
| `NOT_FOUND`        | 404  | The pass id is not a valid wallet-pass id (`wps_` + 32 hex), or no such pass exists.                  | Re-fetch from `GET /wallet-passes` and use the exact `id`.        |
| `CONFLICT`         | 409  | The pass is already voided and cannot be updated or voided again.                                     | Treat void as terminal; issue a replacement pass if needed.       |
