# VXPass Docs
> Public copy of the VXPass admin guide. The canonical web rendering lives at https://vx-pass.web.app/docs.
## Getting started
VXcards lets your customers carry your loyalty program in **Apple Wallet** and **Google Wallet**. Customers register through a web form or via WhatsApp, receive a wallet pass linked to your client account, and you manage them — and send updates — from this admin portal.
### Key concepts
**Client** — Your company's account on VXcards. All customers, passes, and team members are scoped to a client.
**Pass configuration** — The design and field layout of your wallet pass. Edited in the pass builder, then *published* to take effect.
**Campaign** — A one-shot or scheduled message you send to customers. Can include a wallet overlay (visual update on the pass) and/or a push notification.
**Member** — A teammate with a role on your client.
## First steps
1. **Sign in** at the portal.
2. If you're an owner or admin, invite your team in **Team**.
3. Open the **Pass Builder** and customize your pass.
4. **Publish** the configuration.
5. Share your registration link or WhatsApp number with customers.
> **Use case:** **Coffee shop launching a stamp card.** Owner signs up, designs a pass with their brand colors and a "Buy 9, get 1 free" stamp counter on the front, publishes, then prints a QR poster pointing to the registration URL. Customers scan, fill the form, and the pass appears in their wallet — no app download.
## Roles & permissions
Every team member has a role on your client. Roles are hierarchical — each one inherits everything below it.
| Role | Rank | What they can do |
| --- | --- | --- |
| `viewer` | 0 | Read-only across all sections. Cannot see audit logs. |
| `store_manager` | 1 | Viewer + manage promotions, coupons, discounts, approve receipts, run the POS scanner for assigned stores. |
| `editor` | 2 | Full content powers: pass templates, campaigns, loyalty rules, promotions, POS across all stores. |
| `admin` | 3 | Editor + invite/remove members, assign roles, edit client settings, view audit logs. |
| `owner` | 4 | Admin + billing, ownership transfer, client deletion. |
> **Use case:** **Multi-location retailer.** The HQ marketing lead is an `editor` — they design the pass and run cross-store campaigns. Each store has its own `store_manager` who can only review receipts and run POS for their location. The CEO stays as `owner` for billing.
## Pass builder
The pass builder is where you compose a **pass configuration** — the design, fields, and behavior of a wallet pass. The page is split into three top-level tabs: `Pass Builder`, `Nearby Message`, and `Localization`. Inside *Pass Builder*, you'll move through four sections (Basic Info → Front → Back → Graphics) plus a **Live Preview** that updates as you type.
### What the actual screen looks like
### The five sections, explained
**Basic Info** — Pass description, background / foreground / label colors, default language. Sets the global look and the canonical text rendering.
**Front** — What the customer sees without flipping the pass: *header*, *primary*, *secondary*, and *auxiliary* fields. Loyalty points, tier, stamp count, and member name typically live here.
**Back** — Fields revealed when the customer taps the *i* on Apple Wallet (or "Details" on Google Wallet). Long-form info, T&Cs, store hours, support email, and your **back links**.
**Graphics** — Logo (top-left), icon (notification thumbnail), and strip image (banner across the middle). Resized server-side via `sharp`.
**Live Preview** — The right-hand panel renders an Apple Wallet card and (toggled) a Google Wallet card from your current draft. Below it you can expand the raw JSON payload that will be signed into the `.pkpass`.
> **⚠ Warning:** **Publishing does NOT push updates to passes already in customer wallets.**
**Apple Wallet** passes are refreshed on *Refresh All Passes*, when a campaign fires, or when a receipt is approved.
**Google Wallet** objects are re-upserted only on Refresh All Passes, receipts, and campaigns.
## Loyalty configuration
The *Loyalty Configuration* page defines how customers **earn** and **progress**. Without a loyalty config, the pass is just a card.
### Preview
### How points get on the pass
1. Customer makes a purchase and submits a receipt (web upload, WhatsApp photo, or POS scan).
2. OCR extracts the total. Points are calculated using your **amount per point** rule.
3. If a tier multiplier applies, points are scaled before being credited.
4. The customer's loyalty balance updates and their pass is re-synced — the new total appears on the wallet card automatically.
> **Use case:** **Tiered restaurant program.** Bronze members earn 1 pt / $1; once they cross 500 pts they become Silver and start earning 1.25× on every receipt; at 1,000 pts (Gold) they hit 1.5× and the pass's tier field updates to "Gold member" with a gold accent strip.
## Corporate programs
**Programs** are multi-client loyalty alliances. They let you grant special benefits to a group of customers (employees of a corporate partner, students of a school, members of a club) and track their activity across *all* participating clients.
### How they work
- You create a program (e.g. `acme-corp-employees`) and define its benefits — bonus point multiplier, exclusive offers, custom tier overrides.
- Each client opting into the program adds it to their setup. Customers can be assigned by email-domain whitelist or by a unique signup code.
- When a program member earns points at any participating client, the program's multiplier and benefits stack on top of that client's normal loyalty rules.
- The pass shows a **program badge** on the back, plus an extra field if you choose to surface it on the front.
> **Use case:** **Office-tower partnership.** A coworking building runs a "Tower Perks" program. Café Aurora and three other tenants sign up. Anyone with an `@tenants.tower.com` email gets 2× points and a free monthly drink. Each tenant tracks redemptions independently, but the program reports roll up at the tower level.
## Back of pass & links
The *back* of the pass is what the customer sees after tapping the *i* in Apple Wallet (or "Details" / overflow menu in Google Wallet). It's a long, scrollable list of fields and tappable links — your contact info, terms, store list, and any deeplinks back into your app or website.
### Common back-of-pass items
- **Member info** — full name, email, phone, sign-up date.
- **Customer Service** — email and phone, both rendered as tappable links.
- **Special Offers** — a *back link* URL like `https://your-portal/offers/{serialNumber}?token=...`. Tapping opens the customer's personalized offers page; auth is handled by the signed token, no login needed.
- **Terms & Conditions** — a long-form text block.
- **Store locator** — a link to your map or list of locations.
- **Program info** — when the customer is in a corporate program, the program name and its perks.
### Example back-of-pass layout
Back links are powerful because they can carry a signed token specific to the customer — letting you render personalized pages (offers, redemptions, even refer-a-friend) without requiring a sign-in flow.
## Notifications inside the pass
Beyond campaign-driven messages, the pass itself can emit notifications based on **contextual triggers** — without you sending anything.
### Birthday messages
- If a customer's birthday is captured at registration, the pass schedules a wallet update on that date — a celebratory overlay (e.g. "🎉 Happy birthday, Jane! Free pastry today") and a tap-through push notification.
- You configure copy and the offer in *Loyalty* / *Notifications*; everything else is automatic.
### Nearby (location-based)
The **Nearby Message** tab lets you set a default location-triggered message and a **max distance** (20–500 m) at which it fires.
- The pass embeds GPS coordinates of every store registered in *Nearby*.
- When the customer's phone gets within range, the lock-screen shows the nearby message — e.g. *"You're near Rivergate Mall. Show your pass for 10% off ☕"*.
- Per-store overrides let each location speak differently.
- **iOS only** — Apple Wallet renders nearby triggers on the lock screen; Google Wallet does not currently expose this primitive.
### Other in-pass triggers
- **Wallet add** — fires once after the pass is added to the wallet.
- **First receipt** — fires after the first approved receipt.
- **N-th visit** — every Nth visit (configurable).
- **Points threshold** — when the customer crosses a milestone (e.g. 500 pts → silver tier).
All of these are configured in the *Notifications* page and surface either as wallet-side push (Apple) or web-push (browser-registered customers).
> **Use case:** **Foot-traffic boost on slow weekdays.** A café enables a 100-meter nearby trigger at every store with the message *"Tuesday treat — 2× points all day"*. Customers walking past get the lock-screen nudge without any campaign send.
## Customers
The Customers page lists everyone with a pass, plus their registration source, language, and status.
### Preview
### Registration channels
- **Web form** — your public registration link.
- **WhatsApp** — customers send a message to your number; complete an in-WhatsApp registration form.
### Duplicate detection
If a new registration's phone or email matches an existing customer, the system flags it. The customer is not silently overwritten — duplicates surface for review.
## Campaigns & notifications
Campaigns are how you reach customers after they've registered. Two delivery channels, often combined.
### Preview
### Two delivery channels per campaign
- **Wallet overlay** — a visual update applied to the customer's pass (Apple and Google).
- **Push notification** — a tap-through alert delivered through the wallet (Apple) or web push (browser).
> **Use case:** **Win-back for lapsed customers.** Filter to customers whose last receipt was 30+ days ago, send a wallet overlay reading "We miss you — 2x stamps this week" plus a push notification. Sending also re-syncs the recipients' Google Wallet objects.
## SMS campaigns
SMS is a third campaign channel alongside wallet overlays and push. Sent from a per-client 10DLC (US business) phone number, and gated by per-channel consent + a monthly budget cap.
### How provisioning works
1. Platform staff orders a US number for your client through the SMS provisioning wizard on the Companies page. The order is irrevocable, so the wizard locks the client during the call to prevent double-buys.
2. The number is attached to the platform Messaging Profile and registered under the platform's TCR 10DLC campaign.
3. The campaign goes from `pending` → `approved` (usually hours, occasionally days). Sending is blocked until the status reads `approved`. Status syncs automatically; staff can also run a manual sync from the SMS card.
4. Once approved, flip the master `enabled` toggle on the SMS card. SMS now appears as a checkable channel inside the campaign builder.
### Consent rules (carrier-mandated)
US 10DLC and CTIA SCT-G require an explicit, per-channel opt-in for SMS. Marketing consent on the registration form is *not* sufficient on its own.
- **Web registration:** the SMS opt-in checkbox sits under the marketing-consent row with the required disclosure (msg/data rates, STOP/HELP, frequency).
- **WhatsApp registration:** matching opt-in question in the registration flow.
- **Admin override:** staff can grant or revoke consent on a customer's behalf (with audit trail) via the `setCustomerSmsConsent` callable.
- **STOP / HELP:** when a customer texts `STOP`, `BAJA`, `UNSUBSCRIBE`, or `CANCELAR`, we mark them opted out immediately. `HELP` triggers a static reply with the program name and support contact.
- **Existing customers without consent** are excluded from every SMS send. The audience preview's *SMS reach* count shows you exactly how many qualify.
### Segment counting + cost preview
SMS bodies are billed per *segment*, not per message:
- **GSM-7 (plain ASCII + Latin):** 160 chars in 1 segment, then 153 chars/segment for multi-segment.
- **UCS-2 (emoji or non-Latin):** 70 chars/segment, then 67 chars/segment after that. A single emoji can flip the whole message to UCS-2 — a 161-char ASCII message is 2 segments, but adding one emoji makes the same length 3 segments.
The campaign builder shows a live segment counter and a cost estimate (`segments × recipients × $0.0075` for US 10DLC). The reach preview drives the recipient count, so toggling audience filters updates the cost in real time.
### Monthly budget cap
Set `smsConfigs.{clientId}.monthlyBudgetCents` to enforce a hard ceiling. Sends that would push the projected month-to-date spend over the cap are rejected before dispatch, with the skip reason `budget_exceeded` surfaced in the campaign send result. The SMS spend card shows current month, remaining budget, a usage progress bar (warns at 90%), and 6-month history.
Set `monthlyBudgetCents` to `0` or leave it unset to disable the dollar cap. The separate `monthlySendCap` (recipient count) still applies regardless.
### Skip reasons
**`recipient_no_consent`** — `smsConsentAtIso` missing — capture consent via re-opt-in.
**`recipient_opted_out`** — Customer texted STOP / BAJA / UNSUBSCRIBE.
**`recipient_has_no_phone`** — No phone on file.
**`client_sms_not_ready`** — SMS card is not in the *enabled + approved + has-from-number* state.
**`campaign_sms_body_empty`** — SMS channel turned on but body is blank.
**`budget_exceeded`** — Projected spend would exceed `monthlyBudgetCents`.
**`send_failed`** — The SMS provider rejected the send. Check the Cloud Functions logs.
> **Use case:** **Flash sale to opted-in shoppers.** Filter audience to customers who consented to SMS and have at least 1 visit, write a 140-character body, enable SMS only (no wallet overlay), and schedule for Friday 10am. The reach badge tells you the exact send count; the cost preview projects total spend before you publish.
## WhatsApp
WhatsApp messaging runs on top of Meta's WhatsApp Business API. Two distinct use cases share the same connection: **transactional flows** (registration, receipt acks, welcome) and **campaign broadcasts** (announcements, offers, the SMS opt-in invite).
### Preview
### Connection + toggles
Set up under the WhatsApp config page (Companies → WhatsApp tab):
- **Connection** — we provision the phone number and connect it to Meta's WhatsApp Business API. Status flips from `pending` to `connected` once the WABA is live.
- **Default language** — `en` or `es`. Drives template selection when the customer's language is unknown.
- **Receipts via WhatsApp** — opt in to let customers submit purchase receipts as photos.
- **Monthly budget cap** (`monthlyBudgetCents`) — dollar ceiling on outbound marketing-conversation spend. Sends that would exceed the cap abort with skip reason `budget_exceeded`. Set to `0` to disable.
### Template lifecycle
Meta requires every outbound template to be pre-approved per language. Workflow:
1. Submit a template via the WhatsApp config page — one-click "Submit" on each recommended template.
2. Status reads `PENDING` at Meta. Approval typically takes a few hours, occasionally up to 24h.
3. Once status is `APPROVED`, the template appears in the campaign builder dropdown (filtered to MARKETING category — UTILITY templates like receipt acks fire automatically and aren't selectable as broadcast).
**Authoring rule:** template body text cannot start or end with a `{{N}}` placeholder. The system blocks templates that violate this.
### WhatsApp as a campaign channel
Alongside wallet / push / SMS, WhatsApp can be a 4th delivery channel. Because Meta blocks free-form marketing outside the 24h customer-initiated window, campaigns reference an **approved template + parameter values** instead of typing a body. The builder shows a live preview of the template body with values interpolated.
Supported parameter tokens: `{customer.name}`, `{customer.firstName}`, `{brand.name}`.
Audience reach for WhatsApp = customers with a phone (opt-in is implicit by Meta policy for approved marketing templates).
### SMS opt-in invite (one-time per customer)
A predefined campaign archetype that asks legacy customers (registered before per-channel SMS consent existed) to reply `YES` / `SI` via WhatsApp to grant SMS consent.
- Pick it from the chevron menu next to **New Campaign**. The form pre-fills with the right audience filter (no SMS consent + has phone), channel (WhatsApp only), and template (`vxpass_sms_optin_invite`). Most controls are locked.
- The inbound webhook recognizes `YES / SI / SÍ / START / UNSTOP / SUSCRIBIR` as a consent grant — stamps `smsConsentAtIso` + `smsConsentSource='whatsapp_optin_reply'` + sends a confirmation.
- **STOP / BAJA / UNSUBSCRIBE** opts the customer out cleanly (no re-invite for that customer).
- **90-day cool-down**: a customer who already received the invite is skipped on subsequent sends. Meta penalizes accounts that re-send the same MARKETING template too often.
- The campaign detail view shows conversion: *Sent N → Opted in M → X%*.
### Skip reasons
**`recipient_has_no_phone`** — No phone on file — required for WhatsApp send.
**`client_whatsapp_not_ready`** — WhatsApp config not in the *enabled + connected + has-phoneNumberId* state.
**`campaign_template_missing`** — No approved template selected on the campaign.
**`budget_exceeded`** — Projected spend would exceed `monthlyBudgetCents`.
**`optin_invite_too_recent`** — Customer received the SMS opt-in invite within the 90-day cool-down.
**`send_failed`** — The WhatsApp send failed at the provider layer (proxy error, transport error, or Meta rejection). Check the Cloud Functions logs.
### Spend tracking
Per-client rollup at `whatsAppSpend/{clientId}/months/{YYYY-MM}`. The spend card on the Companies page surfaces current-month cost, remaining budget (warns at 90% of cap), and a 6-month history. Meta marketing conversations cost about **$0.025** each in the US — a 50k-customer broadcast = ~$1,250.
> **Use case:** **Backfill SMS consent for existing customers.** A bakery has 5,000 customers who registered before SMS opt-in existed. They run the SMS opt-in invite once via WhatsApp; 1,650 reply YES (33% conversion). Those 1,650 are now SMS-reachable for future flash sales; the rest stay on WhatsApp-only.
## Receipts
If receipts via WhatsApp is enabled, customers can submit purchase photos for loyalty credit.
### Lifecycle
Status values: `pending`, `failed` (OCR couldn't read), or `approved` (auto-approved if confidence is high). Store managers and editors approve, reject, or manually correct.
> **Use case:** **Reward without a POS integration.** A small bakery without a connected POS asks customers to WhatsApp a photo of their receipt. Each approved receipt over $5 adds a stamp.
## Deals & offers
Each **tenant** (a store inside a mall or a single-location business) can publish *offers* that customers redeem in-store. Offers appear automatically on the customer's pass back-of-pass offers link and become redeemable through the POS scanner.
### Three types of offers
| Kind | What it does | Example |
| --- | --- | --- |
| `discount` | A percent or fixed amount off, applied at checkout. The cashier confirms the deal in the POS scanner before completing the sale. | *15% off any pastry* · *$5 off purchases over $30* |
| `visit_milestone` | Reward unlocks after the customer hits a target visit count. The pass shows progress; the cashier redeems when it's full. | *10th visit = free coffee* · *5 visits = a t-shirt* |
| `purchase_threshold` | Reward unlocks once the customer's cumulative spend at this tenant crosses a target — choose either a free item or a discount as the reward. | *Spend $100 → free dessert* · *Spend $250 → 20% off next visit* |
### Creating an offer
The form (a four-section dialog: *Basics*, *Schedule*, *Audience*, *Rule*) is what tenants fill in to publish a deal.
### Where offers show up
- **On the pass** — the back-of-pass *Offers* link opens a personalized page listing every redeemable offer for that customer at every tenant.
- **In the POS scanner** — when the cashier scans the customer's QR, the matching tenant's eligible offers are listed with a *Redeem* button next to each.
- **In campaigns** — you can reference a deal in a wallet overlay or push notification.
> **Use case:** **Stamp-card replacement.** Café Aurora creates a `visit_milestone` offer: "10th visit = free latte". Each receipt or POS scan counts as a visit; the pass shows progress (*7 / 10*). On the 10th visit, the cashier scans the QR, taps *Redeem*, hands over the latte, and the counter resets.
## POS scanner & redemption
The **Tenant POS** page is the operator-facing scanner used at the register. It runs in any modern browser (no app install) and works through three steps: **Pick store → Scan QR → Redeem or mark a visit**.
### Who can use it
- **Store managers** — limited to the stores they're scoped to. The backend rejects scans for stores outside their scope.
- **Editors and above** — can run the scanner at any store of their client.
- **Viewers** — cannot access the POS page.
### What the cashier sees
### How a redemption works under the hood
1. The cashier scans the customer's pass QR. The QR carries a **signed token** tied to that customer's serial number.
2. The backend verifies the token, confirms the cashier's account is allowed to act on this store, and returns the customer's profile + every eligible offer for this tenant.
3. The cashier picks an offer and taps **Redeem**. The backend records the redemption (audit log + offer counter) and re-syncs the customer's pass.
4. If the customer is just walking in (no offer applies), **Mark visit only** records the visit without consuming any reward.
> **⚠ Warning:** **Two-layer permission check.** The frontend only shows the POS link to roles with `redemptions:write`. The backend additionally enforces per-store scope via `assertCanActOnPlace` — so even if a store manager pastes a QR for a customer at a store outside their scope, the redemption is rejected.
### Offline / camera-less fallback
If the browser doesn't expose `BarcodeDetector` (older Safari, some kiosks), a manual paste field appears under the camera.
## Refresh All Passes
The manual button that re-pushes the current pass design to every customer.
#### check_circle When to use it
- After a meaningful pass design change you want existing customers to see.
- After fixing a configuration bug that left passes in a stale state.
#### cancel When not to
- For minor edits no one will notice — it's expensive.
- More than once per change. It re-pushes everyone every time.
### Capacity
Refresh All Passes is bounded by Cloud Functions execution time. For very large customer bases the operation may need to be split into batches — contact support if you have more than a couple thousand customers and the run is timing out.
## Troubleshooting
**The customer can't see my new pass design**
Publishing alone does not update installed passes. Run *Refresh All Passes*, send a campaign, or wait for the next receipt approval.
**Pass not arriving on the phone after registration**
Check the email/SMS link landed.On iPhone, the customer must open the link in Safari, not an in-app browser.For Android, the customer needs Google Wallet installed and signed in.
**Google Wallet shows stale info**
Google Wallet objects are only re-upserted on Refresh All Passes, receipts, or campaigns. Trigger one of those.
**WhatsApp template was rejected by Meta**
Common causes: the template starts or ends with a `{{N}}` placeholder, has unapproved promotional language, or uses formatting Meta doesn't allow.
**A registration was blocked as a duplicate**
Open the dedup flag in the Customers list. You can either merge into the existing record or release it as a separate customer.
**Receipt stuck in "pending"**
OCR confidence was below the auto-approval threshold. A store manager or editor needs to review it manually.
**Campaign didn't reach everyone**
Check that recipients had push enabled.Customers who removed their pass from their wallet won't receive overlays.Web push only works on browsers where the customer accepted the prompt.
## Getting help
When contacting support, include:
- Your **client ID**
- The affected **customer ID** (if applicable)
- An approximate **timestamp**
It makes investigation dramatically faster.