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, undecodablebytes). 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.pemis 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_idon 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 inraw_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_PASSWORDin the service environment for production; never commit real credentials toconfig/*.yml. test: truereceipts are stored withis_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.