Quickstart
From an empty database to a payout the engine has accepted, on your own machine. The steps below stop at an accepted payout; the one command in the first box runs the whole path — address, payment, confirmation, webhook, sweep and settled payout — against a real local chain.
npm run demo:local-chain starts PostgreSQL, a local chain with a test-only token, the engine and this site, and prints a link to the demo. npm run e2e:local-chain runs the same path unattended and checks every step. Nothing touches a public network.Before you start
- Node 22 or newer — the engine uses
node:test, native fetch andAbortSignal.any. - PostgreSQL 14 or newer — local, Docker, or anything you can reach.
- About ten minutes. No account, no API key from anyone, no network access beyond your database.
1. Bring up a database
Anything reachable works. The fastest local option:
docker run -d --name payments-pg -p 5432:5432 \ -e POSTGRES_PASSWORD=payments \ -e POSTGRES_DB=payments postgres:16 # Wait until it answers twice — initdb restarts the server once, # and the first successful connection is often to the temporary one. until psql "postgres://postgres:payments@127.0.0.1:5432/payments" -c 'SELECT 1'; do sleep 1; done
2. Run the engine
npm install npm run build --workspaces --if-present export PAYMENTS_DATABASE_URL="postgres://postgres:payments@127.0.0.1:5432/payments" export PAYMENTS_PORT=8080 export PAYMENTS_OPERATORS="op_abcdefgh:local-development-signing-secret" npm run start:api
The first lines of output are the interesting ones — the engine names every migration it applied, per schema, before it binds the port:
{"event":"database.connected","database":"payments"}
{"event":"migrations.applied","count":15,"directory":"contexts/key-custody/migrations"}
{"event":"migrations.applied","count":12,"directory":"contexts/payouts/migrations"}
// …seven more…
{"event":"http.listening","port":8080,"operators":1}3. Understand the credential you just created
PAYMENTS_OPERATORS is operatorId:secret, comma-separated for more than one. The secret is held by the host process, never by a context — the API is given the ability to check a signature and never the key that checks it. That is what keeps key-bearing material confined to custody, and it is enforced by a lint rule rather than by discipline.
4. Your first signed call
The repository ships a signer so you can try an endpoint before writing any code:
TS=$(date +%s) SIG=$(node tools/testing/bin/sign-request.mjs \ --secret local-development-signing-secret \ --method GET --path /v1/reports --timestamp "$TS") # The query string is on the URL but never in the signature. curl -s "http://127.0.0.1:8080/v1/reports?type=SUMMARY" \ -H "x-operator-id: op_abcdefgh" \ -H "x-timestamp: $TS" \ -H "x-signature: $SIG"
{ "operatorId": "op_abcdefgh", "reportType": "SUMMARY", "builtThrough": null, "rows": [] }An empty report is expected before its projection worker runs. Treasury and reconciliation reads intentionally answer 501 for operator callers until their records have a tenant owner. If you get a 401 instead, it is almost certainly one of four things — the four are listed here.
5. Your first payout
import { PaymentsClient } from '@payments/sdk'; const payments = new PaymentsClient({ baseUrl: 'http://127.0.0.1:8080', operatorId: 'op_abcdefgh', secret: 'local-development-signing-secret' }); const result = await payments.payouts.submit({ payoutId: 'pyo_wd0001a1', asset: 'USDT', amountMinor: '250000000', // 250 USDT in minor units, as a string destination: '0x8Ba1f109551bD432803012645Ac136ddd64DBA72' }, 'withdrawal-00000001'); console.log(result); // { payoutId: 'pyo_wd0001a1', status: 'REQUESTED', outcome: 'ACCEPTED' }
6. Prove the retry is safe
Run the exact same call again. This is the single most important behaviour to see with your own eyes, because it is the one your integration will depend on every time a request times out:
const again = await payments.payouts.submit({ /* identical */ }, 'withdrawal-00000001'); // { payoutId: 'pyo_wd0001a1', status: 'REQUESTED', outcome: 'REPLAYED' } // ^^^^^^^^^^ not a second payout // Now change the amount but keep the key — this is the dangerous case: await payments.payouts.submit({ /* …amountMinor: '999999999' */ }, 'withdrawal-00000001'); // throws PaymentsApiError: 409 — the key was reused with a different instruction
What is next
| To do this | Read |
|---|---|
| Take deposits for customer accounts | Deposits — and note the chain watcher caveat at the top |
| React to confirmations in your app | Webhooks |
| Understand what a payout goes through | Payouts |
| Deploy this somewhere real | Self-hosting |
| See it inside a working application | The live demo — an application cashier wired to this engine |