# @commercelayer/rail0-sdk

> TypeScript SDK for the RAIL0 stablecoin payment API

Latest version **1.2.0** (published 2026-09-24) · MIT license · 0 weekly downloads

## Install

```sh
npm install @commercelayer/rail0-sdk
pnpm add @commercelayer/rail0-sdk
yarn add @commercelayer/rail0-sdk
bun add @commercelayer/rail0-sdk
```

## Health

**Score 75/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; has provenance; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 1.2.0 |
| Published | 2026-09-24 |
| First published | 2026-09-21 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=22 |
| Dependencies | 2 |
| Unpacked size | 600.2 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| GitHub stars | 0 |
| Maintainers | drpierlu, commercelayer.io |
| Keywords | rail0, stablecoin, payments, usdc, erc20, eip-712, erc-4337, web3, ethereum, base, sdk |

## Links

- npm: https://www.npmjs.com/package/@commercelayer/rail0-sdk
- Repository: https://github.com/commercelayer/rail0-ts
- Homepage: https://github.com/commercelayer/rail0-ts#readme
- Issues: https://github.com/commercelayer/rail0-ts/issues
- npm.io page: https://npm.io/package/@commercelayer/rail0-sdk

## Dependencies (2)

- [@noble/curves](https://npm.io/package/@noble/curves.md) ^2.2.0
- [@noble/hashes](https://npm.io/package/@noble/hashes.md) ^2.2.0

## Alternatives

- [flatbuffers](https://npm.io/package/flatbuffers.md) — 6.0M weekly downloads
- [jwt-simple](https://npm.io/package/jwt-simple.md) — 259.5K weekly downloads
- [@exodus/patch-broken-hermes-typed-arrays](https://npm.io/package/@exodus/patch-broken-hermes-typed-arrays.md) — 28.5K weekly downloads
- [@native-to-anchor/buffer-layout](https://npm.io/package/@native-to-anchor/buffer-layout.md) — 12.2K weekly downloads
- [binary-parser-encoder](https://npm.io/package/binary-parser-encoder.md) — 5.3K weekly downloads

## Recent versions

- 1.2.0 (latest) — 2026-09-24
- 1.1.0 — 2026-09-24
- 1.0.0 — 2026-09-21

## README

# @commercelayer/rail0-sdk

TypeScript SDK for the [RAIL0](https://github.com/commercelayer/rail0) stablecoin payment gateway.

RAIL0 brings the authorize → capture → refund lifecycle of card networks to stablecoin payments — no intermediaries, no protocol fees. This SDK is a fully-typed REST client for the RAIL0 gateway in front of the contract, with access to every operation, plus client-side EIP-3009 / EIP-1559 signing helpers (via `@noble` — no ethers/viem dependency). It mirrors the [rail0-go](https://github.com/commercelayer/rail0-go) SDK surface.

## Requirements

- Node.js ≥ 22
- TypeScript ≥ 6 (for TypeScript projects)

## Installation

```bash
npm install @commercelayer/rail0-sdk
# or
pnpm add @commercelayer/rail0-sdk
```

## Quick start

```typescript
import { packSignature, Rail0Client, signPayment, signTransaction } from '@commercelayer/rail0-sdk'

const client = new Rail0Client({ baseUrl: 'https://api.rail0.xyz' })

// 1. Buyer creates the payment (mode: authorize → escrow).
const payment = await client.payments.create({
  chain_id: 8453,
  mode: 'authorize',
  amount: '50.00', // human decimal — the gateway converts to base units
  token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
  payer: '0xBuyer...',
  payee: '0xMerchant...',
})

// 2. Buyer signs the EIP-3009 payload the gateway returned, then stores it.
const sig = signPayment(BUYER_KEY, payment) // { v, r, s }
await client.payments.sign(payment.rail0_id, { signature: packSignature(sig) })

// 3. Payee authorizes: prepare → sign the EIP-1559 tx → submit (funds → escrow).
const authPrep = await client.payments.authorizePrepare(payment.rail0_id)
await client.payments.authorize(payment.rail0_id, {
  signed_transaction: signTransaction(authPrep.unsigned_transaction!, PAYEE_KEY),
})

// 4. Payee captures once the order is fulfilled.
const capPrep = await client.payments.capturePrepare(payment.rail0_id, '50.00')
await client.payments.capture(payment.rail0_id, {
  signed_transaction: signTransaction(capPrep.unsigned_transaction!, PAYEE_KEY),
})

// Inspect live state at any point.
const detail = await client.payments.get(payment.rail0_id)
console.log(detail.status, detail.capturable_amount, detail.refundable_amount)
```

See [`examples/`](examples) for authorize+capture, charge, refund, dispute, and webhooks.

## Payment lifecycle

Each on-chain operation is a two-step **prepare → submit**:

1. **Prepare** — `POST /payments/:id/:op/prepare` — returns a `Transaction` whose `unsigned_transaction` you sign (EIP-1559) with `signTransaction`.
2. **Submit** — `POST /payments/:id/:op` with `{ signed_transaction }` — broadcasts it (HTTP 202, async). Poll `get()` until the status settles.

When a wallet like **MetaMask** signs and broadcasts in one step (so you never hold the raw signed tx), report the resulting hash instead: **submit-by-hash** — `POST /payments/:id/:op/submitted` with `{ transaction_hash }` via `submitByHash(id, op, { transaction_hash })` (payee-only for the merchant ops; `release` accepts either participant). A **buyer** does the same for its own operations with `disputeSubmitByHash(id, { transaction_hash })` and `closeDisputeSubmitByHash(id, { transaction_hash })` (payer-only).

The `:id` accepts **either** the payment's UUID **or** its `rail0_id` (the contract's bytes32 id) — the gateway resolves both.

Payment status values: `unsigned`, `signed`, `authorized`, `charged`, `captured`, `partially_captured`, `voided`, `released`, `refunded`, `partially_refunded`, `expired`. `expired` is a never-captured authorization whose window lapsed (announced by `payments.expired`) and is **not** terminal: the escrow is still on-chain, and `release` closes it as `released`. Status changes are happy-path — a payment only leaves its state to *close*: `partially_refunded` is legacy and no longer produced (a partial refund leaves the status unchanged).

| Operation | Caller | What it does |
|-----------|--------|--------------|
| `authorizePrepare` + `authorize` | payee | Broadcast the authorize tx; funds move to escrow |
| `chargePrepare` + `charge` | payee | One-shot authorize + capture, no escrow window |
| `capturePrepare` + `capture` | payee | Move escrowed funds to the merchant (partial supported) |
| `voidPrepare` + `void` | payee | Cancel the hold, return funds to the payer — **only before any capture** (else the contract reverts `AlreadyCaptured`) |
| `releasePrepare` + `release` | anyone | Return the uncaptured escrow after expiry; closes as `released` only on a **total** release (untouched authorization), else status unchanged |
| `refundPrepare` + `refund` | payee | Two-phase EIP-3009 `receiveWithAuthorization` refund; closes as `refunded` only when **fully settled**, else status unchanged |
| `disputePrepare` + `dispute` | payer | Open a dispute (signal-only) |
| `closeDisputePrepare` + `closeDispute` | payer | Close an open dispute |

## Signing helpers

All client-side, over `@noble` (no ethers/viem).

| Helper | Use |
|--------|-----|
| `signSigningPayload(key, payload)` | Sign any gateway `signing_payload` (mirrors rail0-go's `SignSigningPayload`) — the one generic signer |
| `signPayment(key, paymentDetail)` | Payer signs the EIP-3009 payload from `create()` (authorize or charge) — `signSigningPayload` on `.signing_payload` |
| `signRefund(key, transaction)` | Payee signs the refund payload from `refundPrepare` phase-1 — `signSigningPayload` on `.signing_payload` |
| `packSignature(sig)` | Turn a `{ v, r, s }` into the `0x` r‖s‖v hex every `signature` field expects |
| `signTransaction(unsignedJson, key)` | Sign an unsigned EIP-1559 tx from any prepare step → raw hex for submit |
| `signAuthorize` / `signCharge` | Lower-level EIP-3009 signers from explicit params |
| `signTransferWithAuthorization` / `signReceiveWithAuthorization` | Raw EIP-3009 transfer / receive signers |
| `buildSiweMessage(params)` | Build the EIP-4361 text for a login or a wallet proof-of-ownership |
| `addressFromPrivateKey(key)` | The EIP-55 checksummed address of a private key (`checksumAddress` is the deprecated old name — it never formatted an address) |

`signSigningPayload` takes the payload itself (`null`/`undefined` throws). The two
named wrappers need only the `signing_payload` field, so they accept any
`{ signing_payload }` — a whole `PaymentDetail`/`Transaction`, or just the payload
holder. An unrecognised `primaryType` **throws** rather than defaulting to the
transfer typehash: the gateway's payload is signed verbatim, never rebuilt
client-side.

## Amounts

Amounts you **send** (`create`, `capturePrepare`, `refundPrepare`) are human
decimal strings (`'50.00'`) — the gateway converts them to base units using the
token's decimals. Amounts you **read** back (`amount`, `capturable_amount`,
`refundable_amount`, analytics volumes, the `min_amount`/`max_amount` list
filters) are base-unit integer strings (`'50000000'`).

Rendering one needs the token's `decimals`, and those resolve from `token`
**together with** `chain_id` — a token address identifies a token only within one
chain. Both fields are on every `Payment`, list rows included, so displaying an
amount from `list()` never needs a per-row `get(id)`.

Convert between a human decimal and the token's base-unit integer string, with
string/BigInt math (no float rounding):

```typescript
import { toBaseUnits, formatAmount } from '@commercelayer/rail0-sdk'

toBaseUnits('1.50', 6) // → '1500000'   (USDC has 6 decimals)
formatAmount('1500000', 6) // → '1.5'   (trailing zeros trimmed)
```

Fractional digits beyond `decimals` are truncated; a malformed amount throws.

A static **stablecoin registry** ships with the SDK — `stablecoins` (per chain: `chainId`
and each token's `address`, `decimals`, `eip3009`/`eip2612` support), `chainInfo(chain)`,
and `eip3009Tokens(chain)` / `eip2612Tokens(chain)`. It is a static table shipped with
the SDK, not the gateway's catalogue — that is `tokens.list()`.

## API reference

### `new Rail0Client(options)`

```typescript
const client = new Rail0Client({
  baseUrl:    'https://api.rail0.xyz',
  headers:    { Authorization: 'Bearer ...' }, // optional (required for authed endpoints)
  timeout:    30_000,                          // ms, default 30 000
  maxRetries: 3,                               // default 0 (network errors only)
  retryDelay: 200,                             // ms base, doubles each attempt
  retryOn429: false,                           // retry a rate limit (default false)
  retryAfterCapMs: 60_000,                     // longest Retry-After to honour
  signal:     controller.signal,               // optional — cancels the request and any wait
  logger:     debugLogger,                     // optional — see Logging
})
```

#### Rate limits

The gateway throttles the public surface **per IP** (100 requests / 60s by default) and
everything authenticated **per session**, keyed on the JWT subject (300 / 60s). Over
budget it answers **429** with `code: "rate_limited"`, a `Retry-After`, and (since
rail0-gateway#201) `RateLimit-Limit`/`-Remaining`/`-Reset` on *every* response so you can
pace instead of discovering the wall.

`Rail0ApiError.retryAfter` carries the header in seconds. Read it when you handle the
error yourself:

```typescript
try {
  await client.payments.list()
} catch (err) {
  if (err instanceof Rail0ApiError && err.status === 429) {
    await new Promise((r) => setTimeout(r, (err.retryAfter ?? 5) * 1000))
  }
}
```

`retryOn429: true` makes the client do that waiting — `Retry-After`, clamped to
`retryAfterCapMs`, plus a little jitter (callers sharing one session are told the same
number and would otherwise wake in lockstep). The jitter never shortens a wait below what
it is for: additive on the server's own number, equal jitter — half fixed, half random —
on a guessed one. It is **off by default** on purpose: an
automatic sleep hides back-pressure from the code that could react to it, and in a browser
it turns a rate limit into a frozen click. It also works on its own — you do not need
`maxRetries` as well, which would have made the flag a silent no-op.

A **429 is the only HTTP status this client retries**, on any method including `POST`,
because the gateway rejects it in middleware *before* the request reaches the application:
nothing ran, so nothing can run twice. That is not true of a 502 or a timeout on a
capture, where the broadcast may already be in flight — those are never retried.

`signal` cancels the request **and any retry that is waiting**, which matters precisely
because `retryOn429` can hold a promise for up to a minute.

Resources: `client.payments`, `client.accounts`, `client.wallets`, `client.paymentMethods`, `client.webhooks`, `client.disputes`, `client.analytics`, `client.chains`, `client.tokens`, `client.health`, `client.auth`.

`setAuthToken(jwt)` sets (or, with `null`/`undefined`, clears) the `Authorization: Bearer …` header on every subsequent request. A successful `auth.login()` or `auth.verify()` already calls it for you (as rail0-go's `Auth.Login`/`Verify` do), so a long-lived client is authenticated right after signing in; use `setAuthToken` to install a token you persisted, or to share one session across several clients:

```typescript
const { token } = await client.auth.login(privateKeyHex, 'api.rail0.xyz')
// client.analytics/webhooks/… are now authenticated — no setAuthToken needed.
other.setAuthToken(token) // a second client on the same session
```

### `client.payments`

`create(params, idempotencyKey?)` → `PaymentDetail` (pass `idempotencyKey` to make the create replay-safe — the key is bound to the request, so reusing it with different terms is a `422 idempotency_key_reused`, not a silent replay of the first payment) · `get(id)` → `PaymentDetail` (status + live `capturable_amount`/`refundable_amount` + `transactions`) · `list(params?)` → `PaginatedResponse<Payment>` (JWT) · `transactions(id, params?)` → `PaginatedResponse<Transaction>` · `redrive(id, transactionId)` → `Transaction` · `sign(id, { signature })` → `PaymentDetail` · `disputes(id, params?)` → `PaginatedResponse<Dispute>`.

Prepare/submit pairs (each prepare → `Transaction`, each submit → `Transaction`):
`authorizePrepare`/`authorize`, `chargePrepare`/`charge`, `capturePrepare(id, amount)`/`capture`, `voidPrepare`/`void`, `releasePrepare(id, from?)`/`release`, `refundPrepare(id, body)`/`refund`, `disputePrepare(id, reason?)`/`dispute`, `closeDisputePrepare(id, reason?)`/`closeDispute`. A generic `prepare(id, op, body?, opts?)` / `submit(id, op, params)` is also available, plus `submitByHash(id, op, { transaction_hash })` to record an already-broadcast tx by hash (MetaMask; payee-only, `release` either participant) and the payer-only `disputeSubmitByHash(id, { transaction_hash })` / `closeDisputeSubmitByHash(id, { transaction_hash })`.

`getTransaction(id, transactionId)` reads ONE of a payment's transactions. This is the lookup for an `action_id`: anything handed a transaction id when an operation was accepted resolves it directly, instead of fetching the payment and scanning its transactions for an id it already holds. `redrive(id, transactionId)` re-enqueues a stuck broadcast.

**Idempotency.** Every prepare takes an optional trailing `opts: IdempotentRequest` — `{ idempotencyKey }`, sent as `Idempotency-Key`: the generic `prepare(id, op, body?, opts?)`, the typed `authorizePrepare(id, opts?)`, `chargePrepare(id, opts?)`, `capturePrepare(id, amount, opts?)`, `voidPrepare(id, opts?)`, `releasePrepare(id, from?, opts?)`, `refundPrepare(id, body, opts?)`, and `disputePrepare(id, reason?, opts?)` / `closeDisputePrepare(id, reason?, opts?)`. Without it, a retry arriving after the first transaction was signed and broadcast opens a **second** one — correct for a genuine sequential partial capture, wrong for a retry, and only the caller can tell those apart. Same key with different terms is refused `422 idempotency_key_reused`; the key is scoped to the payment.

```ts
await client.payments.capturePrepare(id, '50.00', { idempotencyKey: orderId })
```

**Refund** is two-phase: `refundPrepare(id, { amount })` returns a `Transaction` carrying a `signing_payload`; sign it with `signRefund`, then `refundPrepare(id, { amount, signature })` returns the unsigned on-chain tx to sign + `refund()`.

### `client.wallets` (scoped by account, JWT)

All wallet methods are behind SIWE — a merchant manages its **own** wallets. `list(accountId, params?)` → `PaginatedResponse<WalletWithTokens>` · `get(accountId, idOrAddress)` → `Wallet` · `create(accountId, { address, message, signature, label? })` → `Wallet` · `update(accountId, id, { label?, active? })` → `Wallet` · `delete(accountId, id)` → `void` · `balances(accountId, id, params?)` → `WalletBalances`.

Accepted tokens: `addToken(accountId, id, { chain_id, token, default? })` → `WalletTokenHolding` (upsert — reactivates a disabled holding instead of duplicating it) · `removeToken(accountId, id, tokenId)` → `void` (soft, keeps the row) · `enableToken(accountId, id, tokenId)` / `disableToken(accountId, id, tokenId)` → `WalletTokenHolding` (404 when the wallet has no holding for that token). `tokenId` is the **token's** UUID (as in `WalletTokenHolding.token.…`), not an id of the holding row.

A wallet with no accepted token is invisible to buyers and unusable as a payee: `GET /payment_methods` skips it and `payments.create` answers 422 `unsupported_payment_method`. Onboarding a merchant is therefore `create` **plus at least one** `addToken`.

**`redrive` is for one shape of stuck, and the SDK tells you which.** A transaction that is `pending` and whose SIGNED bytes the gateway holds — prepared and signed, never landed (a worker that died between the two, a queue drained by hand) — can be handed to the broadcaster again; nothing about the payment changes. `Transaction.redrivable` is the same predicate the gateway guards the route with, so offer the action on that flag rather than discovering a `422`: a `pending` row with no signed transaction is **not** redrivable, and there the next step is submitting the signature, not retrying a send that never happened.

Adding a wallet requires a **SIWE proof-of-ownership** of the address being added — not just the session JWT. `auth.proveAddress(privateKeyHex, domain, chainId?)` runs that handshake and returns the `message` + `signature` to hand to `create`. Sign with **the added wallet's own key**, not the session key: the gateway rejects a signature that does not recover to `address` (422), and an address already registered anywhere (409 — addresses are globally unique). This lets a merchant prove control of several payee wallets under one account.

```ts
const added = addressFromPrivateKey(addedWalletKey)
const { message, signature } = await client.auth.proveAddress(addedWalletKey, 'api.rail0.xyz')
await client.wallets.create(accountId, { address: added, message, signature, label: 'Payouts' })
```

**The proof is purpose-bound, and a login proof will not do.** The gateway pins each endpoint to one statement and refuses the other with 422 `siwe_purpose_mismatch`:

| Endpoint | Statement | Constant |
|---|---|---|
| `POST /auth` | `Sign in to RAIL0` | `LOGIN_STATEMENT` |
| `POST /accounts/:id/wallets` | `Add this wallet to your RAIL0 account` | `WALLET_LINK_STATEMENT` |

That is a security boundary rather than a label: a login signature is handed out on every sign-in, so a wallet-link endpoint that accepted one would let anyone holding a captured login proof bind that address to their **own** account. `proveAddress` picks the right statement for you — reach for `buildSiweMessage` directly only if you are signing with an external wallet or hardware signer, and pass `WALLET_LINK_STATEMENT` when you do.

### `client.paymentMethods` (public)

Buyer-facing discovery of a merchant's accepted wallets/tokens — **no JWT**. `list(query)` → `WalletWithTokens[]`, where `query` is exactly one of `{ account_id }` (all the merchant's active wallets) or `{ address }` (just that one wallet). Maps the public `GET /payment_methods`; an unknown handle yields `[]`.

```ts
const methods = await client.paymentMethods.list({ address: '0xABC…' })
for (const w of methods) for (const h of w.tokens ?? []) {
  // pay h.token (h.token.symbol on h.token.chain_id) to w.address
}
```

### `client.webhooks` (JWT)

`list(params?)` · `create({ name, callback_url, topics })` — one subscription covers a set of events, with one secret and one circuit breaker; two subscriptions on the same URL must not overlap (409) → `WebhookWithSecret` (secret shown once) · `get(id)` · `update(id, params)` · `enable(id)` · `disable(id)` · `rotateSecret(id)` → `WebhookWithSecret` · `resetCircuit(id)` · `eventCallbacks(id, params?)` → `PaginatedResponse<EventCallback>` (filter by `status`, `topic`, `payment_id`, `response_code`, `since`, `until`) · `delete(id)`.

#### Verifying a delivery

Every delivery carries `X-Rail0-Topic`, `X-Rail0-Timestamp` (unix seconds) and
`X-Rail0-Signature` — a hex HMAC-SHA256 over `"{timestamp}.{body}"`, keyed with the
`shared_secret` shown once at create/rotate. `verifyWebhookSignature` checks the digest
in constant time **and** the timestamp against a ±300s window; the timestamp is inside
the signed string precisely so that a captured delivery cannot be replayed forever, so
checking the digest alone is not verification.

```ts
import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER } from '@commercelayer/rail0-sdk'

export async function POST(request: Request) {
  // The RAW body. Verify before JSON.parse — re-serialising a parsed object changes
  // key order and whitespace, and the digest with it.
  const body = await request.text()
  const ok = verifyWebhookSignature({
    body,
    signature: request.headers.get(WEBHOOK_SIGNATURE_HEADER),
    timestamp: request.headers.get(WEBHOOK_TIMESTAMP_HEADER),
    secret: process.env.RAIL0_WEBHOOK_SECRET!,
  })
  if (!ok) return new Response('invalid signature', { status: 401 })

  const event = JSON.parse(body)
  // …handle event.topic
  return new Response(null, { status: 204 })
}
```

It returns a boolean and never throws — a spoofed request is an expected condition on a
public endpoint, and every failure (missing header, blank secret, unparseable timestamp,
stale clock, bad digest) deserves the same 401. `expectedWebhookSignature(body,
timestamp, secret)` is exposed for when you need to diff the two sides to debug a
rejection, and `tolerance` / `nowSeconds` are injectable. No `node:crypto`: the digest
comes from `@noble/hashes`, already a dependency, so the helper works wherever the rest
of the SDK does (a test pins byte-equality with node's implementation).

### Pagination

Every `PaginatedResponse<T>` carries `{ data, meta }`. `meta` is `{ page, per_page, total, total_pages, links }`.

`total_pages` is **zero** for an empty collection — "no pages" is what there are, so a pager rendered off it renders none. `links` comes from the `Link` header: `first` and `last` are always present, `prev` and `next` only where they exist, and the object is empty when the collection is. The URIs are **relative** (path + query) and resolve against the URL you requested — the gateway emits them that way so they cannot advertise the wrong scheme through a TLS-terminating proxy.

```ts
let page = await client.payments.list({ per_page: 100 })
while (page.meta.links.next) {
  page = await client.payments.list({ page: page.meta.page + 1, per_page: 100 })
}
```

### `client.disputes` (JWT)

Account-level dispute list — every dispute (open **and** closed) across the caller's payments, each with its parent `payment` embedded. Complements `payments.disputes(id)` (one payment's history); unlike the `disputed` filter on `payments.list` (current-state), it still surfaces closed disputes.

`list(params?)` → `PaginatedResponse<Dispute>` — `params`: `{ status?: 'open' | 'closed', sort?, page?, per_page? }`.

### `client.analytics` (merchant, JWT + account)

Merchant sales analytics over the account's **own** payments as payee. Account-only: every method needs a JWT with a non-null account — `401` without a token, `403` for an account-less (buyer) session. All three take the same optional `AnalyticsFilters`: `{ mode?, status?, token?, chain_id?, from?, to? }` (`from`/`to` are ISO-8601; `token` + `chain_id` together scope monetary volume to a single token, so sums never mix decimals).

- `summary(filters?)` → `AnalyticsSummary` — `{ orders, disputed, refund_rate, dispute_rate, failed_rate, by_status, failures, volume, gas, gas_by_status, gas_by_operation }`, where `volume` is one `AnalyticsVolume` per `(token, chain)` with base-unit `gross` (authorized), `settled` (net of refunds), `escrowed` (still held), and gross `captured`/`refunded` strings from the confirmed transactions.
  `failures` is one row per decoded failure code with how many transactions hit it, commonest first: `failed_rate` says how much fails, this says what to act on — a revert is a state problem, a rejection that never reached the chain is a wallet problem.
  `gas` is one `AnalyticsGas` per **chain** — `spent` on confirmed transactions, `wasted` by on-chain reverts, in that chain's **native** token (wei-scale strings, `decimals` 18), so it is never summed across chains — plus `confirmed`/`failed` counts and the `failed_rate` derived from them (per resolved **transaction**, not per order). It covers only the operations the merchant broadcasts: `dispute`/`close_dispute` are the buyer's cost and `release` has no stored sender.
  `gas_by_status` and `gas_by_operation` are those same rows regrouped as `AnalyticsGasSlice[]`, each carrying a `key`; every cut sums back to its chain's `gas` row. Chain and status rows also carry `orders`, so `(spent + wasted) / orders` is the average cost of an order in that state — counting the orders that produced no transaction and so cost nothing. It is `null` on the operation cut, where one order spans several operations and `spent / confirmed` (the cost of one occurrence) is the meaningful average. The status cut is a **snapshot** — a payment's status moves and its gas moves with it, so the same period changes over time — while the operation cut is stable.
- `timeseries(filters?, { interval? })` → `AnalyticsBucket[]` — order count per bucket (oldest first); `interval` is `'day'` (default) | `'week'` | `'month'`. `volume` is a base-unit string only when both `token` and `chain_id` are filtered, else `null`.
- `breakdown(filters, { by })` → `AnalyticsRow[]` — aggregate by `by`: `'token'` | `'chain'` | `'mode'` | `'status'` | `'operation'`. `token`/`chain` rows carry `volume`; `mode`/`status` rows are counts only; `operation` groups the merchant's own CONFIRMED transactions and carries `transactions` (how often it ran — a partial capture runs several times on one order) beside `orders` (how many it touched).

```ts
await client.auth.login(privateKeyHex, 'api.rail0.xyz') // authenticates `client`
const kpis  = await client.analytics.summary({ mode: 'charge' })
// Gas is per chain: format each row with its own symbol, never add them up.
for (const g of kpis.gas) console.log(g.chain_name, formatAmount(g.spent, g.decimals ?? 18), g.symbol)
const daily = await client.analytics.timeseries({}, { interval: 'day' })
const byTok = await client.analytics.breakdown(undefined, { by: 'token' })
```

### `client.accounts` (merchant, JWT)

`get(accountId)` → `Account` — the caller's OWN profile (`id`, `name`, `email`, timestamps).

Behind SIWE and behind an ownership guard: the gateway requires a JWT whose account matches
the path, so there is no way to read another merchant's account here. An id that is not an
account answers `404`, the same shape another account's id gets, so the pair cannot be used
to tell whether an account exists.

```ts
const me = await client.accounts.get(session.accountId)
console.log(me.name, me.email)
```

The account's wallets are on `client.wallets` (a collection under the same path), and
buyer-facing discovery on `client.paymentMethods`.

### `client.chains` / `client.tokens` / `client.health`

`chains.list(params?)` → `Blockchain[]` (filter by `{ network_type, symbol }`; each chain carries `contract` — the RAIL0 deployment new payments open against, typed `ChainContract`: `address`, `version`, `deployed_at`, nullable for a chain with no deployment. Since 1.2.0 `Blockchain` is an alias of the schema component, so it types `contract` and picks up future fields on regenerate) · `tokens.list(chainId?, symbol?, active?)` → `Token[]` (every token by default, retired ones included — each carries `active`; pass `active: true` where only what a new payment can use should be offered) · `health.get()` → `Health`.

### `client.auth`

`getNonce()` → `{ nonce, expiresAt }` · `verify(message, signature)` → `AuthResponse` (`token`, `address`, `accountId`, `name`, `expiresAt`, and `admin` — true only for an account holding the operator grant; visibility only, every gated route re-checks it) · `login(privateKeyHex, domain, chainId?)` → `AuthResponse` (full SIWE flow; `chainId` defaults to 1 — override to match a gateway whose `SIWE_CHAIN_ID` differs) · `logout()` → `{ revoked }` (this TOKEN) · `revokeAll(privateKeyHex, domain, chainId?)` → `{ revokedAll, cutoffAt }` · `proveAddress(privateKeyHex, domain, chainId?)` → `{ message, signature }` (the wallet-link proof — see `client.wallets`).

**`login` and `verify` authenticate the client they run on.** On success the token is installed on that client, and still returned in `AuthResponse` for callers that persist it; a failed call leaves the current token untouched. A server that verifies *someone else's* signature (relaying a user's sign-in) therefore holds that user's session on the client afterwards — use one client per sign-in there (as a per-request client already does), or clear it with `setAuthToken(null)`.

**`logout` and `revokeAll` answer different questions.** `logout` ends the session whose token this client carries, so signing out one device leaves the others signed in. `revokeAll` ends **every** session of the calling address — including the ones you have never seen, which is the whole case for a key you no longer trust: five live sessions would otherwise need five tokens you do not hold. The gateway records a **cutoff instant** rather than enumerating tokens, so a session minted a moment earlier is refused by its own `iat`. That instant is what `cutoffAt` carries, and it is the value worth logging: it says exactly which sessions died, which a boolean cannot.

`revokeAll` is authorized by a **fresh SIWE proof** of the address rather than by the session — whoever reacts to a leaked key holds the wallet, not the stolen token — so it takes the private key and the gateway host, like `login`, and signs a message carrying its own statement (`REVOKE_ALL_STATEMENT`, "Sign out of RAIL0 everywhere"), which a login proof cannot replay. The cutoff includes this client's own session: sign in again afterwards.

### Logging

Pass any `(entry: LogEntry) => void` as `logger`, or the built-in `debugLogger`:

```typescript
import { debugLogger } from '@commercelayer/rail0-sdk'
const client = new Rail0Client({ baseUrl: 'https://api.rail0.xyz', logger: debugLogger })
// [rail0] POST 202 https://.../payments/0x.../authorize 87ms
```

## Error handling

Every 4xx / 5xx throws a `Rail0ApiError` carrying the gateway's code/title/detail
triple, plus `.status` (HTTP code) and — on `429` — `.retryAfter`:

```typescript
import { Rail0ApiError } from '@commercelayer/rail0-sdk'

try {
  await client.payments.capture(id, { signed_transaction })
} catch (err) {
  if (err instanceof Rail0ApiError) {
    console.error(err.error)  // branch on this: 'insufficient_token_balance'
    console.error(err.title)  // short label: 'Not enough balance'
    console.error(err.detail) // a sentence you can show a user verbatim
    if (err.status === 429 && err.retryAfter) await sleep(err.retryAfter * 1000)
  }
}
```

**`.error` is the only field to branch on** — the specific condition, read from the
gateway's `code` and falling back to the older `error` sub-code, then to the wider
`status` family, so an older gateway still yields the most specific value it sent.

`.title` and `.detail` come from the gateway's error catalogue, so the same condition
always reads the same way whichever endpoint surfaced it; `.detail` is written to be
shown to a user as-is and is also the thrown error's `.message`.

The codes span four families, and the last two are the ones most requests actually
hit — neither is raised by RAIL0 itself:

| Family | Examples |
| --- | --- |
| Request & state guards | `not_capturable`, `amount_exceeds_refundable`, `not_the_payee` |
| RAIL0 custom errors | `not_payee`, `already_captured`, `refund_expired` |
| Token reverts | `insufficient_token_balance`, `invalid_token_signature`, `authorization_already_used` |
| Broadcast rejections | `insufficient_gas_funds`, `nonce_too_low`, `replacement_underpriced` |

A **failed transaction** carries the same triple as `error_code`, `error_title` and
`error_detail`, whether it reverted on-chain or was refused before broadcast.

`.retryAfter` is the number of seconds parsed from the `Retry-After` response header
on a `429` (the gateway's rate limiter advertises its window), or `undefined`. A `429` is
retried automatically only with `retryOn429: true` (see [Rate limits](#rate-limits));
otherwise back off using this value.

`err.hint` (or `describeError(code)`) is this SDK's own local advice, a *supplement* to
`.detail` rather than a replacement — present only for codes worth adding a next step
to, `undefined` otherwise.

## Development

```bash
pnpm test        # or bin/test (installs dependencies if missing; args pass to vitest)
pnpm typecheck   # src, examples and test
pnpm lint        # biome over src, gen, examples, test

# Regenerate types + resources from the gateway's OpenAPI schema:
#   default source is ../rail0-gateway/docs/openapi.json
#   override with RAIL0_SCHEMA_PATH=/abs/path/openapi.json  (or RAIL0_SCHEMA_URL)
pnpm generate
```

### Publishing

Releases publish from GitHub Actions (`.github/workflows/release.yml`) through npm
**Trusted Publishing**: npm trusts that workflow in this repo, so there is no registry
token to store or rotate and no 2FA prompt, and each tarball carries npm provenance.

1. Merge a PR that bumps `version` in `package.json`.
2. Publish a GitHub release on `main` tagged `v<version>` (e.g. `v1.2.0`).

The workflow checks the tag against `package.json`, runs lint, typecheck, test and build,
and publishes. A version already on npm is skipped, so a re-run (Actions → release → Run
workflow, choosing the tag) is always safe.

One-time setup, by a maintainer of the package on npmjs.com: package → Settings → Trusted
Publisher → GitHub Actions, organization `commercelayer`, repository `rail0-ts`, workflow
`release.yml`.

Publishing by hand still works as a fallback (`pnpm publish:npm` from a clean `main`,
after `npm login`): `prepublishOnly` runs the same gate, and npm asks for a second factor
on every publish when the account has 2FA. Nothing in this repo holds a registry token —
never commit one.

## Project structure

```text
gen/
  generate.ts     regenerates src/api.ts + resources from the gateway OpenAPI

src/
  core/
    error.ts      Rail0ApiError
    http.ts       HttpClient (fetch, timeout, retry, logging, getPaginated)
    backoff.ts    rate-limit wait (throttleDelayMs)
    path.ts       path-segment encoding for ids
  resources/
    types.ts      gateway-vocabulary types (Payment, Dispute, Webhook, …)
    payments.ts   PaymentsResource (lifecycle + disputes)
    disputes.ts   DisputesResource (account-level list)
    accounts.ts   AccountsResource (the caller's own profile)
    wallets.ts    WalletsResource  (CRUD, balances)
    payment_methods.ts  PaymentMethodsResource (public discovery)
    webhooks.ts   WebhooksResource
    analytics.ts  AnalyticsResource (merchant sales rollups)
    chains.ts     ChainsResource
    tokens.ts     TokensResource
    health.ts     HealthResource
    auth.ts       AuthResource (SIWE)
  api.ts          raw types generated from the gateway OpenAPI
  amounts.ts      toBaseUnits / formatAmount
  stablecoins.ts  static stablecoin registry
  signing.ts      EIP-3009 / EIP-1559 signing helpers
  webhook-signature.ts  webhook delivery verification (HMAC + freshness)
  client.ts       Rail0Client — assembles the resources
  index.ts        public re-exports
```

## License

[MIT](LICENSE)

---
_Source: https://npm.io/package/@commercelayer/rail0-sdk · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
