Payments Engine
Simulation demo — non-payable preview

Northwind — cashier

An application you do not own, integrated against an engine you do. Five steps, in the order a real integration happens. All addresses, balances, and events shown are simulated and non-payable. Everything on the right is the exact bytes that crossed the wire.

  1. 01
    Deposit address
    POST /v1/deposit-addresses
  2. 02
    The customer pays
    embedded checkout
  3. 03
    The chain confirms
    signed webhook in
  4. 04
    Its own balance
    no engine call
  5. 05
    Withdrawal
    POST /v1/payouts
Your application — your application owns this half
Account balance
0.00USDT
Account
account_4471
Application ledger
On the wire — exactly what the server sent and received

The whole integration, in one file

This is the application's side, complete. Three engine calls and one webhook handler — the rest of the demo above is a balance, a ledger and some buttons, which you already have.

application/payments.ts
import { PaymentsClient, PaymentsApiError } from '@payments/sdk';

const payments = new PaymentsClient({
  baseUrl: process.env.ENGINE_URL,
  operatorId: process.env.OPERATOR_ID,
  secret: process.env.PAYMENTS_SECRET
});

// 1 — when a customer first opens the cashier
export async function depositAddressFor(customer) {
  const issued = await payments.deposits.issueAddress({
    accountRef: `customer_${customer.id}`,
    chainId: 'eth_devnet'
  });
  return issued.address;
}

// 2 — when the customer asks to cash out
export async function payOut(withdrawal) {
  // Debit your own ledger first, in one transaction with
  // the withdrawal row. The key comes from that row.
  await db.debitCustomer(withdrawal);

  try {
    const result = await payments.payouts.submit({
      payoutId: `pyo_wd${withdrawal.id}`,
      asset: withdrawal.asset,
      amountMinor: withdrawal.amountMinor,  // a string
      destination: withdrawal.destination
    }, `withdrawal-${withdrawal.id}`);

    // ACCEPTED and REPLAYED are both success.
    return result.payoutId;

  } catch (error) {
    if (error instanceof PaymentsApiError
        && error.isIdempotencyConflict) {
      // One key, two instructions. Never retry past this.
      await db.flagForReview(withdrawal.id);
      return null;
    }
    throw error;
  }
}
application/webhooks.ts
import { verifyWebhookSignature } from '@payments/sdk';

// 3 — the engine tells you what happened on-chain.
// express.raw, not express.json: the signature covers bytes.
app.post('/webhooks/payments',
  express.raw({ type: '*/*' }),
  async (request, response) => {

  const rawBody = request.body.toString('utf8');

  const check = verifyWebhookSignature({
    secret: process.env.WEBHOOK_SECRET,
    signatureHeader: request.get('x-webhook-signature'),
    timestampHeader: request.get('x-webhook-timestamp'),
    eventIdHeader:   request.get('x-webhook-id'),
    endpointPath:    '/webhooks/payments',
    rawBody
  });
  if (!check.isValid) return response.sendStatus(401);

  const event = JSON.parse(rawBody);
  const id = request.get('x-webhook-id');

  // At-least-once: the same delivery can arrive twice.
  // Let the unique index decide, not your code.
  const first = await db.query(
    `INSERT INTO processed_webhooks (webhook_id)
      VALUES ($1) ON CONFLICT DO NOTHING RETURNING 1`, [id]
  );
  if (first.rowCount === 0) return response.sendStatus(200);

  if (event.eventType === 'deposit.confirmed') {
    // BigInt, not Number — this is a balance.
    await db.creditCustomer(
      event.accountRef, BigInt(event.amountMinor)
    );
  }

  response.sendStatus(200);
});

Where the boundary falls

ConcernWho owns itWhy
Account balanceYour applicationThe engine has no idea what a customer is owed — credits, refunds, locked funds are yours.
Which address belongs to whomBoth, and they must agreeYou supply accountRef; the engine enforces that no two accounts share an address.
When a deposit becomes creditYour application, on the engine's signalThe engine says "confirmed". Whether that credit is playable immediately is your policy.
Whether a withdrawal is allowedYour applicationKYC, limits, fraud rules — all yours. The engine sees an instruction it has already been told to trust.
That a payout happens onceThe engineIdempotency key and broadcast reservation. This is the guarantee you are buying.
Keys and signingThe engineYour application never touches key material. That is enforced by a lint rule, not a convention.
What actually settled on-chainThe engineAnd it will tell you when your books disagree with it, rather than waiting to be asked.