Skip to content

Mobile POS

Badger Commerce includes a mobile point-of-sale capability that turns a phone into a card terminal using Stripe Tap to Pay. It lets staff and supporters take in-person card payments at events, pop-ups and counters — no dedicated hardware required.

It is driven by the companion badger-pos-app (a separate React Native application) talking to the platform's /v1/pos/* REST endpoints. This page covers what the platform side does and how to operate it; the full endpoint contract is in the API Reference under the POS tag.

What it does

  • Quick payments — enter an amount and tap a card to collect. No catalogue needed.
  • Order building — search the catalogue, build a basket, and take payment against a real order.
  • Agent management — administrators control who can take payments and up to what value.
  • Transaction history — every sale is a first-class order, so it flows through the same reporting, refunds and emails as web orders.

Sales are Orders, not a separate ledger

POS sales are recorded as ordinary Order documents, distinguished by a channel field (POS_QUICK_PAY or POS_ORDER) and a posMetadata sub-document (agent, location, device). This keeps one shape for every sale regardless of source, so checkout, refunds, reporting and order emails all work unchanged.

Quick payments — where there is no catalogue item — are booked against a per-site synthetic quick-pay product, provisioned automatically the first time POS is used on a site. This preserves the invariant that an order's total equals the sum of its line items.

Enabling POS for a site

POS is configured per site under System → Tenants → {site} → Configuration (category pos-config):

Config key Type Default Purpose
pos-enabled boolean false Master switch for the site.
pos-stripe-location-id string — The Stripe Terminal location id used for card-present intents.
pos-allow-quick-payments boolean true Whether amount-only quick payments are permitted.
pos-max-transaction-amount integer (pence) 100000 Per-transaction ceiling; requests above this are rejected.
pos-collection-root-seo-name string — Root collection for the till's catalogue tree. Omit to use all top-level collections.
pos-quick-pay-sku-id string (auto) Set by the platform when it provisions the synthetic quick-pay product. Not user-editable.

Payments use the site's existing Stripe Connect account (StripeAccountDetails), so each tenant's takings settle to its own Stripe account.

Platform fees on in-person sales

In-person sales carry the same Badger application fee as web orders. The fee is calculated from the site's badgerApplicationFeePercentage and badgerApplicationFeeFixed settings (defaulting to 2.4% + 20p) and taken from the tenant's Connect payout, exactly as it is for a card-not-present order.

Both card-present routes are covered:

  • Quick pay — there is no pricing pipeline on this path, so PosService computes the fee when it builds the order and stamps it on the order's Price.
  • Order build — the basket has already been through the priceOrder pipeline, so the fee BasicApplicationFeeCalculator computed is passed straight through.

Either way the fee is recorded on the order as well as sent to Stripe, so it appears in reporting and reconciliation rather than only in the Stripe dashboard.

A site paying through a direct (non-Connect) Stripe account has no platform to collect a fee, so none is applied — Stripe rejects the parameter outright on such accounts.

Granting agent access

Taking payments requires the POS_AGENT role, assigned in the admin user editor. A per-site agent profile governs capabilities:

  • canProcessQuickPayments — may take amount-only payments.
  • canBuildOrders — may build a basket and take payment against it.
  • maxTransactionAmount — an optional per-agent ceiling, on top of the site-wide limit.

Admin users get POS access implicitly for testing, with generous defaults. At login the app calls GET /v1/pos/agents/me, which returns the agent's site name, role, capabilities, Stripe location and the catalogue root — everything the till needs in one round-trip. A user with no POS capabilities receives 403.

The payment flow

There are two payment flows depending on whether the sale has catalogue line items.

Quick pay (amount only, no basket)

  1. The app requests a Stripe Terminal connection token (POST /v1/pos/{locationId}/connectionToken).
  2. It creates a card-present payment intent (POST /v1/pos/{locationId}/paymentIntents) with no orderId. This creates a POS_QUICK_PAY order against the synthetic quick-pay product.
  3. The customer taps their card; the app captures (.../capture) or cancels (.../cancel) the intent. On capture the quick-pay order completes directly — there is no separate submit step.

Order build (card-present against a basket)

When the sale has real catalogue items, payment is taken against the built basket so the order goes through the normal submit pipeline (inventory, order number, confirmation email):

  1. Build the basket (see impersonation below) and request a connection token as above.
  2. Create the card-present intent on the basket: POST /v1/basket/{basketId}/paymentIntent?paymentType=card_present. The response carries the Stripe client_secret in paymentToken and the intent id in thirdPartyId. (Only card_present is supported here; card_not_present / giftcard return 400.)
  3. The reader collects and confirms the payment with that client secret.
  4. Reconcile the intent to authorised: PATCH /v1/basket/{basketId}/paymentIntent/{intentId}. The backend re-reads the intent from Stripe (it never trusts the request body).
  5. Submit the basket: PUT /v1/basket/{basketId}/submit. The order pipeline captures the authorised intent.

To abandon a part-paid basket, DELETE /v1/basket/{basketId}/paymentIntent cancels every not-yet-captured intent on the gateway and clears the order's payment pointer.

Completed sales of either kind appear in GET /v1/pos/{locationId}/transactions and feed the GET /v1/pos/dashboard summary used on the app's home tab.

If the site has never finished Stripe Connect onboarding, POST /v1/pos/{locationId}/connectionToken (and the other Terminal endpoints) return 503 Service Unavailable with a body naming the site — the app should surface "Stripe is not set up on this site — contact your admin" rather than retrying or treating it as an authorisation failure.

Building a basket on behalf of a customer (impersonation)

For the order-build flow the agent needs to make basket calls — PUT /v1/basket, PATCH /v1/basket/{id}/{lineItemId}, POST /v1/basket/{id}/paymentIntent, PUT /v1/basket/{id}/submit — that operate on a customer's basket, not the agent's own. The platform supports this via an impersonation header rather than a separate impersonation login.

Header: X-Badger-Acting-As: <userId>

When present, the request runs as that user. The platform validates the request first:

  • the authenticated principal must hold POS_AGENT with canBuildOrders on the current site;
  • the target user must exist and belong to the same site (siteGroupId).

Anything else — a non-POS principal trying to impersonate, an agent without canBuildOrders, an unknown/foreign userId — is rejected with 403/404 and an audit log line: POS impersonation: agent=… acting-as=… site=… path=…. Orders created during an impersonated request automatically record createdByAgentId (the principal) and default their channel to POS_ORDER, so they show up in POS dashboards.

The header is stateless — there is no impersonation session, no cookie, no "exit impersonation" endpoint. Closing one customer and opening another is simply a matter of changing what the app sends on the next request, the same way a fresh browser tab gives a new session.

At the start of each sale the app should call POST /v1/basket to mint a fresh basket rather than relying on PUT /v1/basket to find-or-create. The platform stamps createdByAgentId and channel = POS_ORDER at order creation; calling POST guarantees that stamp happens on the basket you're about to build, even when impersonating a returning customer who already has a stale basket from a previous web session.

Walk-in customers

In-store walk-ins typically have no User record. The app mints a fresh guest User on demand:

POST /v1/pos/walkin-customers

Returns { "userId": "..." } — a freshly-created anonymous User scoped to the site. The agent uses that id as X-Badger-Acting-As for every subsequent basket call in this sale. The guest is a real User document with registered=false, so the resulting Order has a proper owner and the agent's own order history stays uncluttered.

Customer details and receipts

POS orders skip the web checkout's personal-details step, so the platform seeds the order's contact details at creation: a named (impersonated) customer's own saved name/email are copied onto the order, and a true walk-in falls back to an "In-store customer" placeholder. Because a walk-in placeholder carries no email, the confirmation and dispatch email steps skip silently for it (recording a CONFIRMATION_EMAIL_SKIPPED_NO_RECIPIENT activity entry) rather than failing the submit — so a receipt only goes out when there is actually somebody to send it to. To email a walk-in their receipt, capture an address on the basket (PATCH the personal details) before submit.

Card details captured at capture

When a card-present capture succeeds, the platform reads the Stripe Charge that backs the intent and writes the following onto the order's StripePaymentMethod:

  • card identity — brand, last 4, expiry (month/year), network, issuer country, funding type, Stripe fingerprint;
  • cardholder name — the ISO 7813 name printed on the card, available for contact/contactless EMV but not for Apple Pay / Google Pay;
  • EMV receipt fields — authorization code, application preferred name, dedicated file name, terminal verification results.

All of these are PCI-safe to persist long-term — none are PAN or sensitive authentication data. They are critical for the no-receipt order lookup workflow: when a customer returns asking about a recent in-store purchase, the operator can identify the order from card last 4 + amount + timestamp without having issued a paper receipt.

The enrichment happens synchronously at POST /v1/pos/{locationId}/paymentIntents/{intentId}/capture via PosService.recordCapturedPaymentIntent. The charge.succeeded Stripe webhook handler (ChargeSucceededHandler in the worker) backstops missed enrichments idempotently — for example after a server crash between Stripe capture and the DB write, or for captures performed directly on the Stripe dashboard.

Admin visibility

In-store sales are flagged throughout the admin:

  • the order list shows a POS badge next to the order number for any non-web channel;
  • the order detail page shows an In-store (POS) badge by the heading;
  • the Payment Intents (V2) panel tags a card-present intent as In-store, and the panel now reflects the capture (status CAPTURED with the captured amount) even when the legacy Stripe Terminal capture path is used.

Example flow

POST /v1/pos/walkin-customers
→ 201 { "userId": "65f1c2d3e4b7a2a5c8d9e3f4" }

PUT /v1/basket
X-Badger-Acting-As: 65f1c2d3e4b7a2a5c8d9e3f4
{ "skuId": "sku123", "quantity": 1 }
→ 200 (basket with line item, owner = 65f1c2d3…)

POST /v1/basket/{basketId}/paymentIntent?paymentType=card_present
X-Badger-Acting-As: 65f1c2d3e4b7a2a5c8d9e3f4
→ 200 { "thirdPartyId": "pi_…", "paymentToken": "pi_…_secret_…", "status": "READY_FOR_AUTH" }
   (reader collects + confirms with paymentToken)

PATCH /v1/basket/{basketId}/paymentIntent/pi_…
X-Badger-Acting-As: 65f1c2d3e4b7a2a5c8d9e3f4
→ 200 { "status": "AUTHORISED" }

PUT /v1/basket/{basketId}/submit
X-Badger-Acting-As: 65f1c2d3e4b7a2a5c8d9e3f4
→ 200 (order submitted + payment captured; createdByAgentId = the agent, channel = POS_ORDER)

Barcode scanning

Products and variants carry a barcodes list (EAN/UPC/GTIN/ITF-14 etc.). A single endpoint resolves a scanned code to the right product — and to the right variant when the code lives on one.

Why a list, not a single code

A single SKU routinely carries more than one valid barcode in the wild:

  • inner vs outer pack — EAN-13 on the unit, ITF-14 on the case;
  • legacy vs current — older supplier codes still circulating alongside newer GS1 GTINs after a re-sleeve;
  • manufacturer GTIN revisions where both codes are valid in the channel for a period;
  • promo packs with a temporary code that maps to the same underlying SKU.

Modelling barcodes as a list means a cashier can scan whichever code happens to be on the unit in front of them without the POS app having to handle a "no match, ask the cashier to pick" fallback for every legacy case.

Editing barcodes

Admin → product Data tab carries a Barcodes editor. Add one row per valid code. Variants have their own editor on the variant Data tab — when a variant carries its own GTIN (typical for size/colour variants), a POS scan of that code resolves directly to the variant's SKU.

The legacy Product.upc field is still populated and writable for back-compat with existing integrations; the lookup unions it with barcodes automatically so existing data resolves without migration.

REST: resolving a scan

GET /v1/pos/products/by-barcode/5012345678900
→ 200 {
  "product": { "id": "…", "skuId": "TSHIRT-CLASSIC", "name": "Classic Tee", … },
  "matchedVariantSkuId": "TSHIRT-CLASSIC-L"   // present only if the code lived on a variant
}

When matchedVariantSkuId is set, the POS app should add that SKU to the basket. Otherwise, fall back to product.skuId. A variant match wins over a parent-product match: it's the more specific signal about what the cashier actually scanned.

Returns 404 if no product in the current site carries the code, 403 if the user is not a POS agent, 503 if POS is not configured on the site.

Reference

For the authentication design, the app architecture and the original implementation plan, see the in-repo deep dive: Mobile POS feature plan.