PayPOS

UrPOS

Technical & API Integration Manual · v1.6 · for developers & integration partners


This manual is for engineers integrating with PayPOS — building a connector, consuming the event stream, or embedding a plugin. PayPOS is a multi-tenant cloud POS: a Node serverless API (Vercel, Sydney region) over Postgres, with a Flutter client. All data is scoped partner → venue → terminal.

1. Architecture

2. Authentication

Every data endpoint is fail-closed: a missing/empty credential is a 401, never open. Five credential types:

CredentialHeaderScope
Device keyx-urpos-keyA terminal (per-terminal venue-scoped tokens are in rollout; see current state in the Build docs)
Back-office sessionauthorization: Bearer <jwt>A venue, by role (owner/manager/finance/reservations)
Partner sessionauthorization: Bearer <jwt>A reseller's own venues only — unless flagged platform admin (UrPay), which sees every venue across all resellers
Plugin keyx-plugin-key: plg_…One venue, by granted scopes
KDS sessionauthorization: Bearer <jwt>, role kdsA venue, kitchen-screen-scoped only — accepted by the same endpoints a device key reaches, rejected by staff management

Sessions are HMAC-signed, short-lived (8h — 16h for KDS), timing-safe verified. Venue scoping is enforced server-side on every request (enforceVenue); role capability via requireRole. Login throttling: repeated failed PIN attempts per venue+IP are locked out (DB-backed, 15-minute window).

3. Canonical event model — urconnect.v0

The POS writes business events to a transactional outbox. Envelope:

{
  "v": "urconnect.v0",
  "source": "urpos",
  "kind": "order.closed",     // order.closed | order.merged | day.closed
  "venue": "<venue-uuid>",
  "asOf": "2026-07-17T02:00:00Z",
  "data": { "order_id": "…", "subtotal_cents": 5200, "service_fee_cents": 57,
            "surcharge_cents": 0, "gst_cents": 505, "total_cents": 5757,
            "payments": [ { "method": "card", "amount_cents": 5757 } ] }
}

Amounts are integer cents. GST is Australian inclusive (derived as total ÷ 11). Delivery is at-least-once — consumers must dedupe on the event id.

4. Consuming events — the pull API

Register a plugin (venue-scoped, done by the venue/partner via the admin surface); you receive a one-time plg_… key with granted scopes. Then long-poll:

GET /api/events?since=<cursor>&limit=100
    x-plugin-key: plg_…
→ { "events": [ { "id": 42, "kind": "order.closed", "envelope": {…} } ],
    "cursor": 42, "count": 1 }

5. Core REST endpoints

EndpointPurpose
GET /api/menu?venue=Menu: categories, items, modifiers, availability, images
GET /api/floor?venue=Areas, tables (positions/shapes), open orders, décor
POST /api/ordersopen · add_lines · set_qty · void_line · add_payment · close · transfer_table · merge_orders · set_discount · refund (v1.2)
GET /api/docket?order=&mode=receiptCustomer tax-invoice (ESC/POS + preview)
GET /api/reports?venue=&days=Revenue / category / staff / payment / GST
POST /api/syncOffline replay — idempotent batch of queued ops
POST /api/xero-syncAccounting push — balanced daily journal
GET/POST /api/clockClock On/Off: current shift · settings (gate mode) — new in v1.1
GET /api/clock-autocloseCron only — force-closes shifts left open past threshold — new in v1.1
GET /api/shift-report?venue=&days=Labour — hours by staff / day / week / month + who's on the clock — new in v1.2
GET /api/labour-overview?days=Reseller/platform labour — hours per venue across scope, brand-grouped — new in v1.2
GET /api/config-log?venue=&days=Settings change-log (menu/pricing/floor/brand/modules); scope=all adds sales actions — new in v1.2
POST /api/usersAdmin accounts: list · create · reset_pin (emails the user) · set_role · set_active · audit — oversight-scoped — new in v1.2
GET /api/get-build?pin=PIN-gated app download → 302 to signed APK; every hit logged (IP / device / PIN valid) — new in v1.2

6. Idempotency & offline

The client generates UUIDs for orders and lines, so offline-created records keep their IDs when replayed — inserts are on-conflict-do-nothing and payments dedupe on a client_op_id. Any integration writing to PayPOS should adopt the same discipline: supply a stable idempotency key per operation.

7. Accounting integration (Xero pattern)

The reference connector posts a daily Manual Journal per venue+date: income by category (ex-GST) + a GST line, balanced against tender clearing accounts, so a merchant's bank deposit auto-reconciles. It is idempotent (refuses a duplicate for the same venue+date) and per-venue OAuth (each merchant connects their own org). Note: apps created after 2 Mar 2026 must use Xero's granular scopes (e.g. accounting.manualjournals), not the retired broad accounting.transactions.

8. Labour integration (Deputy pattern) — new in v1.1

A venue can optionally sync Clock On/Off to Deputy — real, live, two-way, not a stub. GET /api/clock?view=settings reads a venue's gate mode; POST /api/clock {action:'settings'} sets it (owner/manager only). When gated, POST /api/clock calls out to the urconnect Deputy connector to check the roster window (±15min grace) before allowing clock-on, then opens/closes a real Deputy timesheet. Fails closed on any Deputy communication error while gated — a hard gate that silently lets people in during an outage isn't a gate. Staff link to their Deputy employee ID via staff.js action=update {deputy_employee_id}.

9. Audit, admin accounts & secure PIN issuance — new in v1.2

Immutable audit ledgers

Two append-only tables, both blocked from UPDATE/DELETE by database triggers:

Oversight is enforced server-side: a platform admin reads across all venues/brands; a reseller reads only their own.

Admin accounts (partner_users)

Individual admin/reseller accounts — a per-person PIN (SHA-256, salted by user id), a role (admin/manager/viewer), plus email and mobile. POST /api/users manages them, scoped so a reseller only ever sees/touches their own brand.

Secure PIN issuance

On create or reset, the new PIN is emailed directly to the account holder (transactional email via a verified sender, with link-tracking disabled so the login URL stays clean). It is returned to the calling admin only if the email could not be sent — so an admin never sees another person's live PIN. Login: app.urpay.com.au/login.

10. Device registry, updates & TMS — new in v1.3

Over-the-air updates (Shorebird code-push)

The app carries a code-push engine. Dart/UI changes ship as patches that installed terminals pull and apply automatically — no reinstall. Only a native/binary change needs a fresh APK. Terminals apply patches during a quiet nightly window (02:00–03:00 local, on the idle screen, no order open) or on the next cold launch; a manager can also apply on demand. Updates never interrupt a sale.

Device registry & version journal

Each terminal reports itself on launch: POST /api/device-checkin (device-key gated) with device_uid, model, serial, platform, app_version, patch_no. The server upserts a devices row, appends a device_version_log entry on any version change, and returns the terminal's resolved profile + pay_rail. Read via GET /api/devices (partner-scoped): every terminal with its version, model, serial, profile, status and last-seen; ?device=<id> adds the version journal.

Per-unit device profile → payment rail

On first sighting the profile is auto-classified from the hardware model (A910S → nuvei; Ingenico DX8000 / countertop PAX → poslink); it is overridable via POST /api/devices (profile, and the rail follows). The app reads its pay_rail and routes card payments accordingly — a POSLink-profiled unit goes straight to POSLink rather than trying Nuvei first.

Terminal management (TMS)

POST /api/devices also sets a friendly label, the true hardware serial, and a status (stock / allocated / deployed / retired) — so the back office doubles as a hardware register: what serial, what type, which venue, what version, what status.

Terminal provisioning — one build, self-pairing — new in v1.4

A terminal's venue is runtime state, not a build-time constant. Previously VENUE_ID was compiled in, which meant one APK per venue; a terminal now binds itself by redeeming a single-use activation code, so one build serves every venue.

IssuePOST /api/terminal-provision (partnerAuth, reseller-scoped): create_stock registers hardware before it has a venue; allocate binds a stock unit to a venue and issues its code; issue_code issues one with no pre-registered hardware; plus revoke_code, retire, set_status. GET returns the caller's hardware and any live codes.

RedeemPOST /api/terminal-activate with { code, device_uid, model?, serial?, platform? }. Device-key gated and device-key only (an owner or demo token cannot provision hardware), per-IP rate limited, and single-use by construction: the claim is an atomic UPDATE … WHERE redeemed_at IS NULL RETURNING, so two terminals racing one code cannot both bind. Returns venue_id, venue_name, brand, mode, profile, pay_rail. Codes default to a 72-hour TTL; every failure mode returns the same message deliberately.

Resolution order in the app: stored binding → VENUE_ID baked at build time → otherwise unprovisioned (activation screen). Builds that already carry a baked venue are unaffected and never prompt.

Device roles & peripheral pairing — new in v1.5

devices.role is one of pos, payment or display. Only a pos device runs the app, redeems an activation code and reports versions. issue_code rejects anything else.

This removes the last compile-time venue dependency. Before it, a tablet driving an integrated terminal found that terminal through build-time defines, so the topology needed a per-venue APK — the same constraint activation codes removed for the venue itself.

11. Pricing, hardware recovery, financials & the licence agreement — new in v1.6

Four partner-scoped endpoints. All four are gated by partnerAuth: either x-urpos-key: <device key> or authorization: Bearer <partner jwt>. A missing or invalid credential is 401. A caller is treated as platform admin when it presents the device key, or a partner token flagged admin; every other partner token is a reseller and is scoped to its own partner. All three tables (venue_licence, partner_pricing, device_recovery) are bootstrapped by a shared guard on every call; if that guard fails the endpoint returns 503 {"error":"pricing schema unavailable"} rather than reading an absent table.

/api/partner-pricing — reseller pricing policy

Reseller-level defaults. Nothing is ever billed from this table: venue_licence remains the only source of truth for what a venue pays, and a policy edit never reprices existing venues.

/api/device-recovery — per-terminal hardware cost recovery

Opt-in per device. monthly_cents is ceil(cost_cents / months), derived on write and stored, so a later change to months cannot silently rewrite what a merchant already signed a schedule for. Status is re-derived from recovered_cents vs cost_cents on every mutation, so a paid-off terminal can never be charged again. A partial unique index enforces one active recovery per device.

A recovery attaches to a devices row, and issue_code deliberately creates none — the device is born when the terminal first activates. A terminal being opted into recovery therefore takes the create_stockallocate path, which registers the unit up front and returns its id alongside the same activation code.

/api/financials — platform admin only

A reseller token gets a hard 403 {"error":"platform admin only"}, not a filtered view. Returning an empty list would leak the shape of the system; the 403 says the door is not theirs. GET only (405 otherwise).

Filters, all optional: q (one box — matches venue name or reseller name on deals, and additionally the recovery label or device serial on recoveries), partner_id, model, settle_via, status (recovery status), format=csv.

GET /api/financials?q=&partner_id=&model=&settle_via=&status=
    authorization: Bearer <platform-admin jwt>
→ { "deals": [...], "recoveries": [...], "totals": {...},
    "by_reseller": [...], "basis": "terms", "basis_note": "…" }
basis: 'terms' is in the payload deliberately. licence_charges is append-only and still unwritten — billing runs are not built — so every figure is derived from current terms and current recovery state, not from charges actually raised. It is what would be charged, not what has been.

/api/licence-agreement — generated POS Software Licence Agreement

GET ?venue_id=&mode=preview|issuetext/html, plus the response header X-Licence-Reviewed: 1|0. GET only (405 otherwise). A reseller may render only its own venues; anything else is 404 {"error":"venue not found in scope"}.

12. Conventions

Versioning: the event schema is pinned (urconnect.v0) so consumers can evolve safely. Webhook push (vs pull) is on the roadmap for v1; today the model is poll + idempotent replay.

13. Getting connected

Contact UrPay to be provisioned a sandbox venue and plugin key. A dedicated sandbox environment (isolated Sydney database) is available for integration testing without touching production data.

PayPOS — Technical & API Integration Manual · v1.6 · August 2026 · UrPay Technologies. Interfaces may evolve; the urconnect.v0 envelope is version-pinned.