Server and API notes

What the README used to carry: the endpoints an agent and the console speak, the one rule that decides whether a captured receipt is kept or retried, how reading them is queued, and the handful of deployment settings that are not obvious.

What it does

The agent talks to two POST+JSON endpoints:

Endpoint Purpose
POST /v1/receipts One POST per captured receipt (bearer device-token auth, idempotent on receipt_id)
POST /v1/health Agent telemetry every N minutes (latest kept per device)
GET /healthz Unauthenticated liveness + DB ping

Plus a JWT-authenticated console API for self-service venue management:

Endpoint Purpose
POST /api/auth/signup /signin Email + password (bcrypt), returns JWT (jwt.secret in config, SYNCAPOS_JWT_SECRET override)
POST /api/restaurants Create a restaurant → generated vendor id (ven_…)
GET /api/restaurants List own restaurants
POST /api/restaurants/:id/devices Generate a device key (dvc_live_…) under the restaurant — one per till, as many as needed
GET /api/restaurants/:id/devices List keys (tokens readable for enrollment) with active/last_seen
PATCH /api/restaurants/:id/devices/:deviceId {"active": false} disables a key (its till’s receipts retry, never lost)
POST /api/auth/change-password Verifies current password, bumps pw_version — all other sessions die instantly
GET POST /api/team, PATCH DELETE /api/team/:id The owner adds managers, suspends them, removes them
GET PUT /api/settings/extraction The model key and slug. Owner only; the key is stored sealed and never returned
GET PUT /api/restaurants/:id/ai/control Turn the assistant on for a venue. Owner only
/api/restaurants/:id/ai/schema/*, /ai/semantic/* Profile a source, review what it proposes, publish a data model. Owner only
/api/pos, /api/pos/:id/types, /api/pos/:id/patterns The vocabulary the extractor reads tickets with — how a till nobody has seen gets onboarded. Owner only

Who may see a venue, and who may change it, are two predicates written once in internal/repository/restaurant_repository.go: the owner sees every venue, an admin and a manager see the venues they were given, and an admin is the one who may set those venues up. A venue nobody gave you answers 404. Device keys made here feed the same devices table the ingestion API authenticates against — receipts bind to the restaurant’s venue_id via the token, regardless of what the payload claims.

The one rule that matters

The agent decides what to do with a queued receipt purely from the HTTP status code:

  • 2xx — agent deletes its only copy. Returned only after the row is committed in Postgres.
  • 400 / 413 / 415 / 422 — agent parks the receipt forever. Returned only for bodies that could never be accepted (broken JSON, no receipt_id, undecodable bytes). When in doubt, we do NOT use these.
  • Anything else (401, 5xx, timeout) — agent retries with backoff. This is the safe default: fixing a token or a server bug later lets every queued receipt flow in.

Duplicates are legitimate (at-least-once delivery): receipt_id has a unique index and retries answer 200 with "duplicate": true.

The extraction queue

Nothing here is metered. It is your machine, your provider key and your receipts, so every receipt that arrives is read. Raw capture and model extraction are still two steps, because they fail differently: capture must never wait on a provider.

Once the receipt row is committed, a row goes into receipt_extractions with status='pending'. That table is the queue. Workers claim disjoint batches with FOR UPDATE SKIP LOCKED, so several workers are safe and no external broker is required. A receipt is admitted once; a model retry does not enqueue it again.

A receipt whose extraction fails keeps its raw bytes, which is the point of storing them: the console still shows the ticket, and fixing a key or a prompt later lets the same receipt be read again.

Layout

cmd/app/          entry point (Echo v4)
config/           YAML config + loader + version ldflags
internal/
  routes/         route registration
  handlers/       HTTP layer — status-code mapping lives here
  services/       validation / accept-park-retry policy
  repository/     sqlx + Postgres, idempotent upserts
  models/         envelope + DB row types
  middleware/     bearer device-token auth
  db/             connection + sql-migrate migrations
  tests/          end-to-end contract tests (real HTTP + real Postgres)
pkg/extract/      the provider clients that read a ticket
web/              the console (React + Vite)
ai/               the assistant runtime (Python) that answers questions
deploy/           the compose stacks, provisioning and operational scripts
docs/             architecture, the agent sync protocol, the POS vocabulary

Run

make migrate_up        # apply migrations (config/migrations.yml)
make rn                # run on 127.0.0.1:4650 (config/config.yaml)
make test              # e2e contract tests against syncapos_test DB
make build             # bin/app with version ldflags

Provision a device (the token goes into the agent’s device_token config):

INSERT INTO devices (token, venue_id, label) VALUES ('dvc_live_xxx', '12345', 'Till 1 — Main St');

Deployment notes

  • Run behind nginx/caddy for TLS. TLS 1.2 must stay enabled (the XP-build agent has no TLS 1.3) and the server must send the full chain including the issuing CA — standard Let’s Encrypt fullchain.pem is fine. XP tills pin the issuing CA, so a chain that a modern browser accepts can still be rejected by a till.
  • body_limit_mb (default 32) must stay well above the agents’ max_capture_bytes (default 5 MB raw ≈ 6.7 MB base64). An oversized body is refused from its Content-Length before the upload is read, so the agent usually sees a connection error and retries forever — the till’s queue stalls (it does not park). The server warns at startup if the limit is under 10 MB.
  • venue_id on stored rows comes from the authenticated device row, not the payload (a token for venue A must not write into venue B’s sales). The payload’s claimed venue survives in raw_envelope; mismatches are logged.
  • Device tokens are stored in plaintext (operators need to read them back when enrolling tills). The DB is the trust boundary; receipts carry no sensitive data. Rate-limit /v1/* at the nginx/caddy layer against token guessing.
  • Set SYNCAPOS_DB_PASSWORD in the service environment for production; never commit real credentials to config/*.yml.
  • test: true receipts are stored with is_test = TRUE — never count them as sales.

Console UI (web/)

React + Vite (JavaScript), Notion-style design, served at / by the console container. Sign up / sign in, restaurants sidebar, device keys with live sync status (health telemetry, last-seen, agent config copy block), receipts with pagination, and a raw-data drawer per receipt (decoded text / hex dump / verbatim envelope JSON). Dev: cd web && npm install && npm run dev (port 3000 is CORS-allowed). In the stack it is built into its own container, so there is nothing to build by hand.