Node SDK
A typed client that signs correctly, retries what is safe to retry, and never lets an amount become a number. It is the reference implementation of the protocol, and a conformance test asserts its signatures against the engine's own verifier.
Install
npm install @payments/sdk
No dependencies beyond Node itself. It uses node:crypto and the global fetch, so it runs anywhere Node 22 does.
The client
import { PaymentsClient } from '@payments/sdk'; const payments = new PaymentsClient({ baseUrl: 'https://engine.internal', operatorId: 'op_abcdefgh', secret: process.env.PAYMENTS_SECRET, // Optional timeoutMs: 10_000, // per attempt maxAttempts: 3, // the first included fetchFn: myFetch // for tests, or a proxied runtime });
Methods
| Method | Endpoint | Notes |
|---|---|---|
payouts.submit(request, key) | POST /v1/payouts | The key is a required second argument, not an option. It refuses a non-string amount before anything reaches the wire. |
payouts.get(payoutId) | GET /v1/payouts/:id | The identifier is URL-encoded, so a path segment cannot carry a traversal. |
deposits.issueAddress({ accountRef, chainId }) | POST /v1/deposit-addresses | Idempotent per account and network. |
assets.list() | GET /v1/assets | |
payouts.wallets() | GET /v1/payout-wallet | The payout wallet and the fee wallet. Fund both. |
webhooks.subscribe({ endpointUrl }) | POST /v1/webhooks/subscriptions | Every event type unless you list some. |
deposits.list(accountRef, chainId?) | GET /v1/deposits | |
treasury.positions(asset) | GET /v1/treasury/positions | Currently 501 for operator callers; deployment-global data has no tenant owner. |
reconciliation.openFindings(asset?) | GET /v1/reconciliation/findings | Currently 501 for operator callers; deployment-global data has no tenant owner. |
reports.get(type, asset?) | GET /v1/reports | Read builtThrough, not only the rows. |
exports.create(request) | POST /v1/exports | Idempotent on the job ID. |
exports.get(jobId) | GET /v1/exports/:id | |
webhooks.subscriptions() | GET /v1/webhooks/subscriptions | |
webhooks.verify(options) | — | Local. Verifies a delivery with this client's secret. |
health() | GET /health/ready | Unsigned on purpose: a health check that needs a valid signature cannot tell "down" from "clock drifted". |
request(method, path, body?, opts?) | anything | The escape hatch. Signs and retries exactly as the typed methods do. |
Errors
import { PaymentsApiError, PaymentsTransportError } from '@payments/sdk'; try { await payments.payouts.submit(request, key); } catch (error) { if (error instanceof PaymentsApiError) { error.status; // 409 error.problem.detail; // the engine's message error.problem.requestId; // grep the engine log for this error.isRetryable; // 429 or 5xx error.isIdempotencyConflict; // 409 — never retry with a fresh key } if (error instanceof PaymentsTransportError) { // Never reached the engine, or the answer never came back. // The instruction may still have landed — retry with the same key. error.attempts; } }
What the client retries, and why re-signing matters
Transport failures, 429, 502, 503 and 504 are retried up to maxAttempts. A 4xx is not: it is the caller's to fix, and repeating it repeats the mistake.
401. The idempotency key stays constant across attempts — that is what makes the retry safe. A hand-rolled client must do both, and getting only one right is the most common integration bug.Webhook verification
import { verifyWebhookSignature } from '@payments/sdk'; const { isValid, reason } = verifyWebhookSignature({ secret: process.env.PAYMENTS_WEBHOOK_SECRET, signatureHeader: headers['x-webhook-signature'], timestampHeader: headers['x-webhook-timestamp'], eventIdHeader: headers['x-webhook-id'], endpointPath: '/webhooks/payments', rawBody, // the bytes, not a parsed object toleranceSeconds: 300 // default });
Constant-time comparison, delivery ID bound into the signed string, and a tolerance window checked before any HMAC work. See Webhooks.
Primitives, for another language
If you are implementing the protocol elsewhere, these are the two functions that constitute it. They are deliberately dependency-free and are asserted against the client and the engine by the same test, so they cannot drift from what the server accepts.
import { referenceSignRequest, referenceVerifyWebhook } from '@payments/sdk'; const signature = referenceSignRequest( secret, 'POST', '/v1/payouts', timestamp, idempotencyKey, body ); const ok = referenceVerifyWebhook( secret, signatureHeader, timestampHeader, eventId, '/webhooks/payments', rawBody );
Lower level still, if you want to build the string yourself:
import { createCanonicalRequest, createSignedHeaders, computeBodySha256 } from '@payments/sdk'; createCanonicalRequest({ method, path, timestamp, idempotencyKey, body }); // throws if the path carries a query string, or the timestamp is not unix seconds — // both silent 401s if a client trims them itself instead