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

# Wallet Passes (Apple and Google)

> After this ships, a member adds their loyalty card to their phone's wallet from a link in an email — no app download — and it updates itself when their balance changes.

## Context

A wallet pass is the loyalty card inside Apple Wallet or Google Wallet: balance, tier and a
scannable code at the point of sale. It removes the single biggest friction in the whole category —
"download our app" — and replaces it with a link.

For Latin America it matters more than elsewhere. Android dominates the device base and Google
Wallet ships preinstalled, so the addressable share is close to universal, and the pass updates by
push without any app of ours on the device.

ADR-020 places this as the middle tier of the mobile strategy. One thing it got wrong, corrected
here: **wallet passes are always issued under Softcrum's own accounts, never the client's.** The
app needs the client's developer account because it competes for a store listing and Apple's
guideline 4.3 pushes it there; a pass has no listing, and its branding — logo, colours,
organization name — travels inside the pass file itself. A tenant who wants something that goes out
entirely under their own name gets the white-label app, which is what that tier is for. Offering a
client-account wallet tier would sell the same thing twice and add certificate management for every
tenant.

That makes one spike **blocking rather than tier-shaping**: if Apple requires a separate Pass Type
ID per brand, passes under our own account may not be possible at all, and this feature changes
shape. It is answered before ADR-022 is written.

## Scope *(normative)*

* Pass generation for Apple Wallet (`.pkpass`, signed) and Google Wallet (JWT-based objects).
* Per-tenant branding driven by the same theming configuration as the mobile app.
* Balance, tier and a scannable member code on the pass face.
* Push updates on balance and tier change, through the existing notification cascade.
* `loyalty.wallet_pass_registrations` tracking which devices hold which pass.
* A signed, expiring add-to-wallet link suitable for embedding in an email.

## Non-scope *(normative)*

* Payment. These are loyalty passes, never payment instruments.
* Location-triggered lock-screen relevance in the first version. It is one of the strongest
  features of the format, but it needs per-tenant venue coordinates, which is its own data problem.
* Offline validation at the point of sale. The code is scanned and validated against our API.

## Behaviour *(normative)*

1. Passes are issued under **Softcrum's own Apple and Google accounts, always**. Client-owned
   certificates are explicitly not offered — a tenant wanting their own channel gets the
   white-label app (ADR-020). Signing keys are stored as secrets and rotatable without reissuing
   every pass; a leaked key that cannot be rotated is a full reissue to every member.
2. The add-to-wallet link is signed and **expires**. A link that never expires is a permanent
   credential sitting in an email inbox.
3. Pass updates go through `NotificationChannelPort` like any other channel (ADR-019), with its own
   adapter. Bypassing the cascade here would create a second, unaudited delivery path.
4. Update pushes are **coalesced**: several balance changes within a short window produce one push.
   A member whose phone buzzes on every point earned will remove the pass.
5. A member may hold the pass on several devices. Every registered device receives updates.
6. Unregistering a device stops updates for it and never affects the others.
7. The scannable code is a member identifier that is **useless without authentication** — scanning
   it grants nothing on its own. It is an identifier, not a bearer token.
8. Pass updates are category `product`, not `marketing` — they are a state change on something the
   member deliberately installed.

## Data *(normative)*

| Table                               | Key invariants                                                                                               |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `loyalty.wallet_passes`             | unique per (program, contact, platform); `serial_number`; `auth_token` hashed; `last_pushed_at`; `is_active` |
| `loyalty.wallet_pass_registrations` | FK pass; `device_id`, `push_token`; `registered_at`, `unregistered_at` nullable; append-only                 |

## API *(normative)*

| Endpoint                                 | Class   | Permission        | Budget                            |
| ---------------------------------------- | ------- | ----------------- | --------------------------------- |
| `POST /v1/loyalty/wallet-passes`         | Runtime | member token      | p95 \<1 s (generation is heavier) |
| `GET /v1/loyalty/wallet-passes/{serial}` | Runtime | pass auth token   | p95 \<300 ms                      |
| Apple/Google web service callbacks       | Runtime | platform protocol | per platform spec                 |

The platform callbacks follow Apple's and Google's own protocols, which are not ours to design.
They are the one place in the module where the path shape is dictated externally.

## Events *(normative)*

`loyalty.wallet_pass.issued`. Balance and tier changes are consumed from the existing events rather
than generating new ones.

## Acceptance criteria *(normative)*

1. A generated `.pkpass` validates against Apple's signature requirements and installs on a device.
2. A Google Wallet object installs from the same link on Android.
3. A balance change pushes an update within 60 s to every registered device.
4. Ten balance changes inside the coalescing window produce exactly one push.
5. Key rotation issues valid passes without invalidating installed ones.
6. An expired add-to-wallet link is rejected with a typed error.
7. **Negative:** the scannable code alone grants no access to any endpoint — proven by a test that
   attempts it unauthenticated.

## Execution

Blocked on the signing-library ADR (DEC-H5). Asynchronous pipeline: generation and pushes run in
`backend/workers`; the wallet update adapter is a `NotificationChannelPort` implementation.

## Open questions

None outstanding. Every question this spec carried was answered in the consolidated register
([`../../../design/open-questions-v1.md`](/design/open-questions-v1), v1.1) and folded
into the normative sections above.

Still blocked, but not by a decision:

| # | Item                                                                                           | Nature                           | Status                                                                                                        |
| - | ---------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| 1 | **Spike, now blocking:** can one Apple Pass Type ID serve every tenant with per-pass branding? | Research                         | must be answered first — if not, passes under our own account may be impossible and the feature changes shape |
| 2 | Signing libraries and certificate lifecycle for both platforms                                 | Needs **ADR-023**, not an answer | this FS stays `draft` until it exists                                                                         |

## Changelog

| Version | Date       | Change                                                                     | Why                                      | Author                 |
| ------- | ---------- | -------------------------------------------------------------------------- | ---------------------------------------- | ---------------------- |
| 0.2.0   | 2026-08-17 | Open questions resolved (OQ-LOY-\*) and folded into the normative sections | Owner answered the consolidated register | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Initial draft                                                              | —                                        | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
