Payments Engine

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

shell
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

node
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
});
Server-side only. The client holds an operator secret. A browser that constructs one has published your credential to every visitor. Both the console and the demo on this site sign on their server for exactly this reason — it is why they have a server at all.

Methods

MethodEndpointNotes
payouts.submit(request, key)POST /v1/payoutsThe 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/:idThe identifier is URL-encoded, so a path segment cannot carry a traversal.
deposits.issueAddress({ accountRef, chainId })POST /v1/deposit-addressesIdempotent per account and network.
assets.list()GET /v1/assets
payouts.wallets()GET /v1/payout-walletThe payout wallet and the fee wallet. Fund both.
webhooks.subscribe({ endpointUrl })POST /v1/webhooks/subscriptionsEvery event type unless you list some.
deposits.list(accountRef, chainId?)GET /v1/deposits
treasury.positions(asset)GET /v1/treasury/positionsCurrently 501 for operator callers; deployment-global data has no tenant owner.
reconciliation.openFindings(asset?)GET /v1/reconciliation/findingsCurrently 501 for operator callers; deployment-global data has no tenant owner.
reports.get(type, asset?)GET /v1/reportsRead builtThrough, not only the rows.
exports.create(request)POST /v1/exportsIdempotent 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/readyUnsigned on purpose: a health check that needs a valid signature cannot tell "down" from "clock drifted".
request(method, path, body?, opts?)anythingThe escape hatch. Signs and retries exactly as the typed methods do.

Errors

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

Every attempt is signed afresh. The engine admits a given signature once, so replaying the first attempt's headers would turn a transient failure into a permanent 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

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

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

node
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