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.
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.
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; } }
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); });
| Concern | Who owns it | Why |
|---|---|---|
| Account balance | Your application | The engine has no idea what a customer is owed — credits, refunds, locked funds are yours. |
| Which address belongs to whom | Both, and they must agree | You supply accountRef; the engine enforces that no two accounts share an address. |
| When a deposit becomes credit | Your application, on the engine's signal | The engine says "confirmed". Whether that credit is playable immediately is your policy. |
| Whether a withdrawal is allowed | Your application | KYC, limits, fraud rules — all yours. The engine sees an instruction it has already been told to trust. |
| That a payout happens once | The engine | Idempotency key and broadcast reservation. This is the guarantee you are buying. |
| Keys and signing | The engine | Your application never touches key material. That is enforced by a lint rule, not a convention. |
| What actually settled on-chain | The engine | And it will tell you when your books disagree with it, rather than waiting to be asked. |