# VXPass Docs

> Public copy of the VXPass admin guide. The canonical web rendering lives at https://vx-pass.web.app/docs.

<!-- section:getting-started -->

  
## 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.

  

<!-- section:first-steps -->

  
## 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.

<!-- section:roles -->

  
## Roles & permissions

  
Every team member has a role on your client. Roles are hierarchical — each one inherits everything below it.

  <div class="docs-table-wrap">
    
| 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. |

  </div>
  
> **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.

<!-- section:pass-builder -->

  
## 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.

<!-- section:loyalty -->

  
## 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.

<!-- section:programs -->

  
## 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.

<!-- section:back-of-pass -->

  
## 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.

<!-- section:pass-notifications -->

  
## 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.

<!-- section:customers -->

  
## 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.

<!-- section:campaigns -->

  
## 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.

<!-- section:sms -->

  
## 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.

<!-- section:whatsapp -->

  
## 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: `&#123;customer.name&#125;`, `&#123;customer.firstName&#125;`, `&#123;brand.name&#125;`.

  
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/&#123;clientId&#125;/months/&#123;YYYY-MM&#125;`. 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.

<!-- section:receipts -->

  
## 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.

<!-- section:deals -->

  
## 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

  <div class="docs-table-wrap">
| 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* |
</div>
  
### 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.

<!-- section:pos -->

  
## 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.

<!-- section:refresh -->

  
## Refresh All Passes

  
The manual button that re-pushes the current pass design to every customer.

  <div class="two-col">
    <div class="col col-do">
#### 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.
</div>
    <div class="col col-dont">
#### 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.
</div>
  </div>
  
### 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.

<!-- section:troubleshooting -->

  
## 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.

<!-- section:help -->

  
## Getting help

  
When contacting support, include:

  

    - Your **client ID**

    - The affected **customer ID** (if applicable)

    - An approximate **timestamp**

  
  
It makes investigation dramatically faster.
