Payments Engine

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.

See all of it first. With Docker installed, 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 and AbortSignal.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:

shell
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
You do not create any tables. The engine discovers every migration directory at boot and applies them under an advisory lock, so two instances starting at once cannot both migrate. See Self-hosting.

2. Run the engine

shell
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:

stdout
{"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}
The order is deliberate. Configuration is validated, then the database connects, then migrations run, and only then does the port open. A process that accepted requests before its schema was current would answer some of them wrongly rather than refusing all of them.

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.

Never ship this secret to a browser. If a page can construct a signed request, every visitor holds your operator credential. Sign on your server. The console and the demo on this site both do exactly that, and it is the reason they have a server at all.

4. Your first signed call

The repository ships a signer so you can try an endpoint before writing any code:

shell
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"
200 OK
{ "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

node
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:

node
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
That 409 is the engine protecting you. It means your own bookkeeping produced two different instructions under one key, which is a bug worth finding before it becomes a duplicate payment. Do not retry it with a fresh key until you know which instruction was correct.

What is next

To do thisRead
Take deposits for customer accountsDeposits — and note the chain watcher caveat at the top
React to confirmations in your appWebhooks
Understand what a payout goes throughPayouts
Deploy this somewhere realSelf-hosting
See it inside a working applicationThe live demo — an application cashier wired to this engine