
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.
https://app.urpay.com.au/api/* (white-label resellers use their own branded host), AU-resident.urconnect.v0) and never calls third-party APIs directly; connectors consume events and/or the REST API. Payments are handled by the certified terminal SDK — no card data ever touches PayPOS.Every data endpoint is fail-closed: a missing/empty credential is a 401, never open. Five credential types:
| Credential | Header | Scope |
|---|---|---|
| Device key | x-urpos-key | A terminal (per-terminal venue-scoped tokens are in rollout; see current state in the Build docs) |
| Back-office session | authorization: Bearer <jwt> | A venue, by role (owner/manager/finance/reservations) |
| Partner session | authorization: Bearer <jwt> | A reseller's own venues only — unless flagged platform admin (UrPay), which sees every venue across all resellers |
| Plugin key | x-plugin-key: plg_… | One venue, by granted scopes |
| KDS session | authorization: Bearer <jwt>, role kds | A 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).
urconnect.v0The 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.
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 }
| Endpoint | Purpose |
|---|---|
GET /api/menu?venue= | Menu: categories, items, modifiers, availability, images |
GET /api/floor?venue= | Areas, tables (positions/shapes), open orders, décor |
POST /api/orders | open · add_lines · set_qty · void_line · add_payment · close · transfer_table · merge_orders · set_discount · refund (v1.2) |
GET /api/docket?order=&mode=receipt | Customer tax-invoice (ESC/POS + preview) |
GET /api/reports?venue=&days= | Revenue / category / staff / payment / GST |
POST /api/sync | Offline replay — idempotent batch of queued ops |
POST /api/xero-sync | Accounting push — balanced daily journal |
GET/POST /api/clock | Clock On/Off: current shift · settings (gate mode) — new in v1.1 |
GET /api/clock-autoclose | Cron 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/users | Admin 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 |
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.
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.
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}.
Two append-only tables, both blocked from UPDATE/DELETE by database triggers:
staff_actions (venue-scoped) — discounts, comps, voids, refunds, and settings changes (menu / pricing / floor / brand / modules), each with the acting staff member, action, detail, reason and timestamp. Read via GET /api/config-log.admin_audit (partner-scoped) — admin-account actions (create / reset_pin / role / suspend) and logins. Read via POST /api/users {action:'audit'}.Oversight is enforced server-side: a platform admin reads across all venues/brands; a reseller reads only their own.
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.
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.
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.
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.
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.
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.
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.
Issue — POST /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.
Redeem — POST /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.
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.
POST /api/terminal-provision · {action:'pair', pos_id, peripheral_id, link_host?, link_port?, link_serial?} — binds an integrated payment terminal to the POS device that drives it. Both must be in scope and at the same venue. action:'unpair' reverses it.POST /api/terminal-activate now returns a peripheral object (host, port, serial, pay_rail) when one is paired, or null. The app caches it and resolves its POSLink configuration from it at runtime.role='pos'. A peripheral can never be on an app version, so counting it would report the estate permanently behind.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 policyReseller-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.
partner_id to manage another brand's policy. A reseller naming someone else's partner_id gets 403 {"error":"outside your scope"} — never a silently redirected write. No resolvable partner is 400.GET → { pricing, brand, applies_to }. pricing is the partner_pricing row or null if none saved; brand is { name, slug }; applies_to is a fixed string stating the defaults apply to new venues only.pricing fields: partner_id, model (none|per_device|per_venue|percent_gtv), amount_cents, percent_bps, period (monthly|annual), settle_via (commission|invoice_reseller|debit_merchant), pass_on_licence, recovery_default_months (1–120), recovery_default_cost_cents, notes, updated_by, created_at, updated_at.POST { action:'save', … } → { ok:true, pricing } (upsert on partner_id). Unknown values fall back to the defaults rather than erroring: an unrecognised model becomes none, an unrecognised settle_via becomes commission, recovery_default_months is clamped to 1–120, notes truncated at 500 chars. Any other action is 400. Each save writes an admin_audit row (pricing.policy_save), best-effort./api/device-recovery — per-terminal hardware cost recoveryOpt-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.
404 {"error":"device not found in scope"} / "recovery not found in scope".GET ?venue_id=&device_id= → { recoveries, totals, candidates }. Both filters are optional; a platform admin with no filter gets everything.
recoveries row is the device_recovery record — id, device_id, venue_id, partner_id, label, cost_cents, months, start_on, monthly_cents, recovered_cents, status (active|complete|waived), notes, created_at, updated_at — joined with device_label, serial, model, venue_name, brand.totals: active_count, monthly_cents, outstanding_cents (active rows only), recovered_cents (all rows).candidates is populated only when venue_id is supplied: the venue's role='pos' devices with no active recovery, as { id, label, serial, model }. Otherwise [].POST { action:'start', device_id, cost_cents, months?, start_on?, label?, notes? } → { ok:true, recovery }. months defaults to 24 and is clamped 1–120; cost_cents is required (400 if zero/absent); label defaults to the device's label, model or serial. A device that already has an active recovery is 409 { error, id }.POST { action:'update', id, cost_cents?, months?, start_on?, label?, notes? } → { ok:true, recovery }. Recomputes monthly_cents and re-settles status. A row that is not active is 409.POST { action:'record', id, amount_cents } → { ok:true, recovery, applied_cents, capped }. The instalment is capped at the outstanding balance — the overshoot is dropped, not banked — and capped is true when that happened. Status flips to complete the moment recovered_cents reaches cost_cents. Non-active row → 409; missing amount → 400.POST { action:'waive', id, notes? } → { ok:true, recovery } with status:'waived'. The row stays on the record.POST { action:'delete', id } → platform admin only (403 otherwise), and 409 { error, recovered_cents } if anything has been recovered — money already collected must not vanish from the ledger. Otherwise { ok:true, deleted:<id> }. Use waive for a real recovery being written off; delete is for one that should never have existed.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_stock → allocate path, which registers the unit up front and returns its id alongside the same activation code./api/financials — platform admin onlyA 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": "…" }
deals — one row per non-archived venue: venue_id, venue_name, partner_id, reseller, brand, model, amount_cents, percent_bps, period, settle_via, override_devices, starts_on, notes, device_count (role='pos' devices with status deployed or allocated), billed_devices (override_devices if set, else device_count), monthly_cents. A venue with no venue_licence row reads as model:'none'.monthly_cents is null — not 0 — for percent_gtv, which cannot be resolved without turnover. A zero would silently understate every total on the page. Annual periods are divided by 12.recoveries — one row per terminal: id, status, cost_cents, months, monthly_cents, recovered_cents, start_on, label, serial, device_model, device_status, venue_id, venue_name, archived_at, partner_id, reseller, plus the derived outstanding_cents and at_risk.at_risk is true when a recovery is active, still owes money, and its device is return_pending, returned or retired, or its venue is archived — money owing on a terminal that is on its way out.totals: venues, monthly_licence_cents, percent_gtv_deals, active_recoveries, monthly_recovery_cents, outstanding_cents, at_risk_cents, at_risk_count.by_reseller (sorted by monthly licence, descending): partner_id, reseller, venues, monthly_licence_cents, percent_gtv_deals, active_recoveries, monthly_recovery_cents, outstanding_cents, at_risk_cents. Venues with no partner roll up as Unassigned.format=csv returns text/csv; charset=utf-8 as an attachment (urpos-financials.csv): one header row, then a DEAL row per venue and a RECOVERY row per terminal, amounts as dollars.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 AgreementGET ?venue_id=&mode=preview|issue → text/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"}.
mode=issue returns 409 {"error":"licence agreement not legally reviewed", detail} until the environment variable LICENCE_AGREEMENT_REVIEWED is set. The gate is in code, not in a note someone has to remember. While it is unset, mode=preview still renders but is watermarked DRAFT — NOT FOR SIGNATURE.venue_licence row, its non-waived device_recovery rows (per-terminal cost, term, monthly amount, start date and outstanding balance) and the live count of role='pos' devices in service.venues.settings->'receipt' (with a fallback to the venue's address JSON) — they are not columns on venues.{ "error": "message" } with a standard HTTP status (400 bad input, 401 unauth, 403 scope/venue, 404 not found, 409 conflict, 503 not configured).urconnect.v0) so consumers can evolve safely. Webhook push (vs pull) is on the roadmap for v1; today the model is poll + idempotent replay.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.
urconnect.v0 envelope is version-pinned.