Skip to content

PayPal sandbox only. No real money moves.

LearnHow it works

System architecture, end to end

Every part of GAVEL from the browser to PayPal and the disk, and how a bid, a hold, a voice bid and a webhook travel through them.

Verifiedbacked by a real run or committed evidence

Page last changed 7 Nov 2026 (from the git history)

If you have never seen the code, read this page first. It answers one question: when someone bids, which programs run, where do they run, and where does the money part sit?

System architecture, end to end

Sandbox PayPal only
System architecture, end to endThree bands. At the top, the browser: a static web app, microphone capture and the PayPal approval popup. In the middle, the Render host, one Node process with an HTTP API, a room socket, a voice socket, the room runtime, the engine, the hold service, the settlement saga, the webhook route and a disk with the room log, engine ledgers, payment journals, holds, webhook ids, audit log and kill switch. On the right, outside services: Deepgram and Groq for speech and the PayPal sandbox. At the bottom, GitHub Actions, the Render deploy hook, the uptime probe and the docs site build.BROWSERRENDER WEB SERVICE gavel-host: ONE NODE PROCESS, ONE INSTANCEOUTSIDE SERVICESBUILD AND OPERATEHTTPSwss /wswss /audio, audio framesapproval popup,direct to PayPalbiddecidesettleeventcalls to PayPalroom logbuild and publish the docs siteStatic hostingCloudflare PagesWeb appbidder, auctioneer console, auditMic captureaudio framesPayPal SDK v6approval popupHTTP API/api/*, /healthzRoom socket/wsVoice socket/audioRoom runtimerooms, confirm gate, mailboxSpeech chainDeepgram, Groq, cachedHold serviceorders, authorizeEnginerules, ledgerSettlement sagajournal, capture, voidWebhook route/webhooks/paypalDISK /var/data, STORE=file (1 GB, one owner)rooms.jsonlroom logledgers/engine ledgerjournals.jsonsaga journalsholds.jsonhold recordswebhooks.jsonseen event idspayment-audit.jsonlkill-switch.jsonSTORE=postgres: room log and journals in PostgresDeepgramGroqPayPal sandboxorders, payments, webhooksGitHub Actionsci, nightly, gatesRender deploy hookstaging, productionUptime probe/healthz /readyz /warmupDocs sitedocsite.yml, Cloudflare PagesBrass rule on top: a part that calls PayPal. Hatched: held, sandbox only. Dashed line: written to disk.

Scroll the figure sideways, or open it full size.

Pick a path to light up the boxes and arrows it uses.

Hover, focus or tap a box for what it does and which file implements it.

Verified against packages/server/src/boot/compose.ts, host/node-ws.ts, host/audio-ws.ts, voice/stt.ts, render.yaml, docs/DEPLOY.md and the workflows named in the boxes. The whole chain has been run against the fake PayPal; the PayPal sandbox gates in docs/GATES.md cover single calls, not a complete auction. The web hosting box follows docs/DEPLOY.md; the repository has no workflow that deploys the web app.
Text version of this diagram

Reading order, top to bottom.

  1. Browser: the web app talks to the host over HTTPS and the room socket, the microphone sends frames to the voice socket, and the PayPal popup talks to PayPal directly.
  2. Host: the HTTP API and the room socket both feed the room runtime. The voice socket feeds the speech chain, which hands a decision to the room. The room asks the engine to decide each bid and starts the settlement saga after the hammer.
  3. The hold service (called by the HTTP API) and the settlement saga make the money calls. The webhook route also asks PayPal to check each signature, and PayPal sends events back to it, which feed the saga.
  4. Disk: room log, engine ledgers, saga journals, hold records, webhook ids, the payment audit log and the kill switch file. With STORE=postgres the room log and journals move to Postgres.
  5. Outside: Deepgram, Groq and the PayPal sandbox.
  6. Build and operate: GitHub Actions, the Render deploy hook, the uptime probe and the docs site build.

The short version

  • One browser app for bidders, the auctioneer and the big screen. It is static files.
  • One server process (a single Node program) that owns everything stateful: the live rooms, the auction engine, the payment logic and the files on its disk.
  • PayPal is called only by the server, and only by two parts of it: the hold service (to create and authorize a hold) and the settlement saga (to capture the winner and release the rest). The only direct contact between a browser and PayPal is the approval popup.
  • Speech to text also runs on the server. The microphone sends audio frames and the server turns them into a bid decision.

Why one process and one instance

render.yaml pins numInstances: 1 and puts the data directory on a persistent disk. The room log, the ledger, the journals and the holds are files owned by one process, and a data directory lock stops a second host from opening them. The price is that GAVEL does not scale out; the benefit is that the order of writes, which the money logic depends on, is easy to reason about.

Four journeys through the boxes

Use the buttons under the diagram to light each one up.

A tap or typed bid

The web app sends a bid message on the room socket (/ws). The room runtime checks it with the engine (ascending price, the bidder's ceiling, the lot state). The event goes into the room log first; the engine ledger is written before the event is acknowledged. Everyone in the room then receives the update.

Registration and the hold

The bidder registers with a ceiling. The HTTP API creates a PayPal order with intent AUTHORIZE through the hold service. The bidder approves in the PayPal popup (which talks to PayPal directly). The host then authorizes the order, stores the hold and registers the bidder. See Registration and holds.

A voice bid

Audio frames go to the /audio socket. The speech chain turns them into text (Deepgram, then Groq, then cached replay), a parser reads an amount and a decision step decides whether to ask for confirmation. A decision reaches the room through Room.submitVoiceBid. See Voice and the confirm gate.

Settlement and the webhook

After the hammer the room starts the settlement saga. The hammer is already in the engine ledger; the saga saves its journal, then captures the winning price and voids every other hold. PayPal later posts events to /webhooks/paypal; the host verifies the signature with PayPal, stores the event id once and hands the event to the settlement service. See Settlement saga and Webhooks.

Where each thing runs

The service gavel-host (render.yaml): one instance, Node 22, a health check on /healthz, a 1 GB disk at /var/data, STORE=file, PAYMENTS=sandbox, PAYPAL_ENV=sandbox. Auto deploy is off; deploys come from the deploy workflows through the Render deploy hook.

What is on the disk

rooms.jsonl
<auctionId>.ledger.jsonl
journals.json
holds.json
webhooks.json
payment-audit.jsonl
kill-switch.json

With STORE=postgres the room log, snapshots, token denylist and journals live in Postgres; the ledgers, holds, webhook ids and the kill switch stay in the data directory. The data and ledger model explains each record.

Entry points the server exposes

Prop

Type

Where this lives in the code

Edit on GitHub

Last updated on