@txnod/sdk
v1.2.1
Published
TypeScript SDK for the txnod non-custodial crypto payment gateway — sign requests, verify webhook signatures, and verify invoice deposit-address derivation across BTC, ETH, TRON, Cardano, Polygon PoS, BNB Smart Chain, and TON. Pure Node >= 20; runs in any
Downloads
210
Readme
@txnod/sdk
Fully-typed REST client and webhook verifier for TxNod — a non-custodial crypto payment gateway. Zero cryptocurrency runtime dependencies, automatic HMAC signing on every API call, RFC 7807 → typed error mapping covering every server error_code, and a verifyWebhookSignature helper with a ±300-second timestamp window. Pure Node ≥ 20 — runs unchanged in Express, Fastify, Hono, Koa, NestJS, plain node:http, Next.js (App Router or Pages), Nuxt, SvelteKit, Remix, Astro endpoints, or any other server-side framework. Production and testnet are both supported end-to-end, including SDK-side address verification — pick at project-create time, never per-invoice.
Install
npm install @txnod/sdk
pnpm add @txnod/sdk
yarn add @txnod/sdk
bun add @txnod/sdkNode ≥ 20 is required. Zero runtime and zero peer dependencies — @txnod/sdk is the only package you install. Types are fully expanded to plain TypeScript in the published tarball, so the SDK is compatible with any zod version your project uses (or no zod at all).
Environment setup
# .env.local
TXNOD_API_KEY_ID=01JXXXXXXXXXXXXXXXXXXXXXXX
TXNOD_API_SECRET=<64-hex-character API secret>
# Optional — defaults to https://txnod.com.
# TXNOD_BASE_URL=https://pay.mycompany.comThe operator generates the API Key ID and API secret in the TxNod dashboard (API Keys → Generate). TXNOD_API_KEY_ID is the value sent as the X-Project-Id header. TXNOD_API_SECRET is your only secret — it both signs API requests and verifies inbound webhook signatures. The secret is shown exactly once — copy it into your secrets manager immediately.
TXNOD_BASE_URL is only needed when targeting a non-default origin — a staging deployment, or a self-hosted txnod-compatible gateway. The SDK's HMAC scheme, retry logic, and webhook verifier are endpoint-agnostic.
Testing without a real wallet — sandbox mode
Sandbox projects work end-to-end without on-chain interaction: create one in the dashboard, drive client.sandbox.simulate* to walk the state machine, and your webhook handler receives real signed events with mode: 'sandbox'. The SDK surface is documented in docs/05-sandbox.md (bundled in this tarball at node_modules/@txnod/sdk/docs/05-sandbox.md); an end-to-end Vitest harness covering all seven webhook event types is at docs/examples/sandbox-vitest-suite.md.
Supported coins
Operators enable coins per-project in the TxNod dashboard; a single TxnodClient instance can create invoices for any enabled coin — no SDK-level coin-capability toggle. The coin value passed to createInvoice must be one of the 15 literals below.
| Coin enum | Chain | Description |
|---|---|---|
| btc | btc | Bitcoin native (BTC). |
| eth | eth | Ethereum native (ETH). |
| usdt_erc20 | eth | Tether USD, ERC-20 on Ethereum. |
| usdc_erc20 | eth | USD Coin, ERC-20 on Ethereum. |
| trx | tron | TRON native (TRX). |
| usdt_trc20 | tron | Tether USD, TRC-20 on TRON. |
| ada | ada | Cardano native (ADA). |
| pol | polygon | Polygon PoS native (POL). |
| usdt_polygon | polygon | Tether USD, ERC-20 on Polygon PoS. |
| usdc_polygon | polygon | USD Coin, ERC-20 on Polygon PoS. |
| bnb | bsc | BNB Smart Chain native (BNB). |
| usdt_bep20 | bsc | Tether USD, BEP-20 on BNB Smart Chain. |
| usdc_bep20 | bsc | USD Coin, BEP-20 on BNB Smart Chain. |
| ton | ton | TON native (Toncoin). |
| usdt_ton | ton | Tether USD jetton on TON. |
Coin/chain helpers and amount conversion
The package root exports a small set of pure helpers for working with coins, chains, and on-chain amounts:
COIN_TO_CHAIN— aRecord<Coin, Chain>map;coinToChain(coin)is the function form. Use either when an API that speakscoin(invoices) meets one that speakschain(orphan payments).COIN_DECIMALS— aRecord<Coin, number>of base-unit decimal places per coin.toBaseUnits(coin, humanString)— converts a human decimal ("1.5") into integer base units. Throws on malformed input, too many fractional digits for the coin, or a zero result.fromBaseUnits(coin, baseUnitsString)— the inverse: formats integer base units (e.g. an invoice/webhookamount_unitsstring) as a human decimal, trimming trailing zeros.
import { coinToChain, COIN_DECIMALS, toBaseUnits, fromBaseUnits } from '@txnod/sdk';
coinToChain('usdt_trc20'); // 'tron'
COIN_DECIMALS.usdt_bep20; // 18
// BEP-20 trap: usdt_bep20 / usdc_bep20 / bnb use 18 decimals on-chain, unlike
// the 6-decimal ERC-20 / TRC-20 / Polygon stablecoins (and TON, which is 9).
// Always round-trip `amount_units` through fromBaseUnits rather than assuming 6.
fromBaseUnits('usdt_bep20', '1500000000000000000'); // '1.5'
fromBaseUnits('usdt_trc20', '1500000'); // '1.5'
toBaseUnits('usdt_bep20', '1.5'); // '1500000000000000000'SDK-side address verification (TXNOD_<chain>_XPUB)
When the matching env var is set, createInvoice automatically derives the expected deposit address from the operator's xpub at the invoice's derivation_path and rejects with AddressVerificationError on mismatch. This is the SDK's defense against a compromised gateway silently swapping the deposit address. Set per chain you accept; omit to skip verification on that chain.
| Chain | Env var | Format |
|---|---|---|
| BTC | TXNOD_BTC_XPUB | zpub (mainnet) / vpub/tpub (testnet) |
| ETH + ERC-20 | TXNOD_ETH_XPUB | xpub (mainnet) / tpub (testnet) |
| TRON | TXNOD_TRON_XPUB | xpub / tpub |
| Cardano | TXNOD_CARDANO_ACCOUNT_PUBKEY | CIP-5 acct_xvk bech32 |
| Polygon PoS | TXNOD_POLYGON_XPUB | xpub / tpub |
| BNB Smart Chain | TXNOD_BNB_XPUB | xpub / tpub |
| TON | TXNOD_TON_PUBKEY (+ siblings) | 64-hex Ed25519 pubkey — see below |
Hardware-wallet users (Ledger or Trezor): ETH, Polygon PoS, and BNB Smart Chain share BIP-44
coin_type 60. One device account → one xpub → set the same value on all three env vars (TXNOD_ETH_XPUB,TXNOD_POLYGON_XPUB,TXNOD_BNB_XPUB). The wizard's "EVM" hint mirrors this; the SDK's per-chain address derivation differs in chain-id only. (TRON and TON are Ledger-only on the hardware-wallet side — Trezor doesn't speak either chain. Cardano via Trezor requires Model T or newer.)
Each xpub env var accepts three formats:
- Single xpub — the most common case:
TXNOD_BTC_XPUB=zpub6r…. - CSV (newest-first) — for wallet rotations where multiple xpubs must be accepted in parallel:
TXNOD_BTC_XPUB=zpub6_new,zpub6_old. Verification iterates newest-first and accepts on the first match; anAddressVerificationErroris raised only when every configured xpub fails to derive the address. - JSON array — equivalent to CSV when you prefer explicit shape:
TXNOD_BTC_XPUB=["zpub6_new","zpub6_old"].
For TON the operator's wallet has no HD derivation — the address is fully determined by (walletPublicKey, walletVersion, subwalletId, workchain). Configure via four env vars (defaults match a stock Ledger TON setup):
| Env var | Required | Default |
|---|---|---|
| TXNOD_TON_PUBKEY | yes (enables auto-verify) | — |
| TXNOD_TON_WALLET_VERSION | no | v4R2 (allowed: v3R2, v4R2, v5R1) |
| TXNOD_TON_SUBWALLET_ID | no | 698983191 (standard for v3R2/v4R2) |
| TXNOD_TON_WORKCHAIN | no | 0 (basechain; allowed: 0, -1) |
The pubkey is the 32-byte Ed25519 public key the operator's wallet device exports — the same value Ledger Live, Tonkeeper, etc. expose under "show recovery key" / "wallet pubkey". Never the secret key. When TXNOD_TON_PUBKEY is unset, TON verification is skipped silently — the SDK will emit one warn line per unverified TON invoice so operators do not silently coast on server trust.
Wallet rotations: after changing the env value in a long-lived process, call client.refreshXpubConfig() to pick up the new values without restarting.
TRON requires per-address activation
TRON is the only chain in the list that charges the recipient to "activate" each fresh address before it can hold balance. TxNod's operator dashboard handles activation on the operator side; partners do nothing extra. The only partner-visible artifact is a single typed error: when an operator's TRON address pool has zero activated rows at invoice creation time, createInvoice({ coin: 'trx' | 'usdt_trc20' }) raises TxnodTronNoActivatedAddressesError. The error's walletId field carries the operator wallet id — surface a "your operator has not finished TRON wallet setup" message and do not auto-retry; the operator must activate addresses through the dashboard before TRON invoices succeed. The canonical handler example is in docs/02-invoices.md (bundled in this tarball).
TON: payment-token matching
TON is a memo chain — every TON invoice shares the operator's single deposit address and is disambiguated by an 8-hex payment_token placed in the on-chain transaction comment. createInvoice({ coin: 'ton' | 'usdt_ton' }) returns the token alongside the deposit address:
import { TxnodClient } from '@txnod/sdk';
declare const client: TxnodClient;
// `qrcode` is partner-installed; the SDK does not depend on it.
declare const qrcode: { toDataURL: (input: string) => Promise<string> };
const invoice = await client.createInvoice({
amount_usd: 5,
coin: 'ton',
external_id: 'order-ton-1',
});
// invoice.payment_token === 'a1b2c3d4' (8 lowercase hex chars)
// invoice.payment_uri === 'ton://transfer/UQ.../?amount=5000000000&text=a1b2c3d4'
// Render the URI as a QR code on the checkout page:
const dataUrl: string = await qrcode.toDataURL(invoice.payment_uri);The wallet apps (Tonkeeper, MyTonWallet, Ledger Live) auto-fill the payment_token into the comment field when the user follows the URI or scans the QR — no manual paste required. If the user types the address into a wallet by hand and forgets the comment, the deposit lands on the operator's wallet but cannot be matched to the invoice. Such deposits surface in the operator dashboard's "Unattributed deposits" view rather than as invoice.paid webhooks. Surface a "scan the QR or copy the entire payment_uri" instruction at checkout to keep this rare. For HD chains (BTC / ETH / TRON / ADA / Polygon / BSC), invoice.payment_token is always null.
Quickstart — create an invoice
A complete app/api/checkout/route.ts against the Next.js App Router:
import { randomUUID } from 'node:crypto';
import { TxnodClient, TxnodCoinNotEnabledError, TxnodError } from '@txnod/sdk';
const client = new TxnodClient({
projectId: process.env.TXNOD_API_KEY_ID!,
apiSecret: process.env.TXNOD_API_SECRET!,
// baseUrl defaults to 'https://txnod.com'. Override for a self-hosted or
// staging deployment (see TXNOD_BASE_URL above):
...(process.env.TXNOD_BASE_URL ? { baseUrl: process.env.TXNOD_BASE_URL } : {}),
});
export async function POST(request: Request): Promise<Response> {
try {
const invoice = await client.createInvoice({
amount_usd: 9.99,
coin: 'usdt_trc20',
external_id: randomUUID(),
callback_url: new URL('/api/txnod-webhook', request.url).toString(),
});
return Response.json({
invoice_id: invoice.id,
pay_to: invoice.address,
amount_crypto: invoice.amount_crypto,
payment_uri: invoice.payment_uri,
expires_at: invoice.expires_at,
});
} catch (err) {
if (err instanceof TxnodCoinNotEnabledError) {
return Response.json(
{ error: 'coin_not_enabled', request_id: err.request_id },
{ status: 422 },
);
}
if (err instanceof TxnodError) {
return Response.json(
{ error: err.error_code, request_id: err.request_id },
{ status: err.status },
);
}
throw err;
}
}Per-invoice amount matching — matching_mode
createInvoice accepts an optional matching_mode that controls how strictly a deposit's amount must match the invoice at attribution time:
| Mode | Meaning | Use for |
|---|---|---|
| exact | The deposit amount must equal the invoice amount. | Standard fixed-price checkout. |
| at_least | Any amount ≥ the invoice amount matches (overpayment is accepted). | Tips on top of a price, "pay at least X". |
| any | Any amount matches. | Donations, pay-what-you-want, top-ups. |
Omit the field to inherit the project's configured default (set in the dashboard). The chosen mode is echoed back on every invoice response as matching_mode.
import { TxnodClient } from '@txnod/sdk';
declare const client: TxnodClient;
// Donation: accept whatever the supporter sends.
const donation = await client.createInvoice({
external_id: 'donation-2026-06',
coin: 'usdt_trc20',
amount_usd: 5, // suggested amount; `any` accepts more or less
matching_mode: 'any',
});
console.log(donation.matching_mode); // 'any'Saturation caveat (
overlay_matching_mode_required_exact, HTTP 422): when the operator's address pool is fully allocated, a new invoice may have to share a deposit address with another invoice. In that case TxNod can only tell the two apart by amount, so onlyexactis permitted —at_leastandanyreject withTxnodOverlayMatchingModeError. Catch it and retry withexact, or surface "checkout temporarily unavailable" and notify the operator to grow the pool.
Quickstart — verify a webhook
A complete app/api/txnod-webhook/route.ts that validates the inbound HMAC and narrows the typed event:
import {
verifyWebhookSignature,
TxnodHmacError,
TxnodTimestampError,
} from '@txnod/sdk';
export async function POST(request: Request): Promise<Response> {
const rawBody = await request.text();
try {
const event = verifyWebhookSignature(
request.headers,
rawBody,
process.env.TXNOD_API_SECRET!,
);
if (event.event_type === 'invoice.paid') {
// event.data is fully typed under the `invoice.paid` discriminant.
console.log('paid invoice', event.data.invoice_id, 'at', event.created_at_iso);
}
return Response.json({ ok: true });
} catch (err) {
if (err instanceof TxnodHmacError) {
return new Response('bad signature', { status: 401 });
}
if (err instanceof TxnodTimestampError) {
console.warn('clock skew', err.skew_seconds);
return new Response('stale signature', { status: 401 });
}
throw err;
}
}verifyWebhookSignature(headers, rawBody, secret) is framework-agnostic on both arguments:
headersaccepts either the WebHeadersobject (shown above) or any plain header bag whose values arestring | string[] | undefined— a structural superset of Node'sIncomingHttpHeaders, so Express/Fastifyreq.headerswork unchanged. The accepted shape is exported as the typeWebhookHeadersInput.rawBodyacceptsstring | Buffer. Node HTTP frameworks hand back the raw body as aBuffer, so you can pass it straight through with no.toString()boilerplate.
import express from 'express';
import { verifyWebhookSignature } from '@txnod/sdk';
const app = express();
// Capture the *raw* bytes — HMAC is computed over the exact payload, so this
// must run before any JSON body parser rewrites the buffer.
app.post('/api/txnod-webhook', express.raw({ type: '*/*' }), (req, res) => {
// req.headers is a WebhookHeadersInput (IncomingHttpHeaders); req.body is a
// Buffer — both accepted by verifyWebhookSignature with no conversion.
const event = verifyWebhookSignature(req.headers, req.body, process.env.TXNOD_API_SECRET);
if (event.event_type === 'invoice.paid') {
console.log('paid invoice', event.data.invoice_id);
}
res.json({ ok: true });
});Webhook idempotency and reorg restores — read this
Key your webhook idempotency on the envelope's unique event_id — never on tx_hash. A blockchain reorg can roll back a confirmed payment and then re-confirm it, and the SDK surfaces that cycle explicitly so you don't lose (or silently drop) the restore:
- The invoice confirms →
invoice.paidwithevent_id = A(anddata.reasonabsent). Fulfill the order. - A reorg orphans the confirming block →
invoice.reverted. Reverse the credit. - The payment re-lands on the canonical chain → a new
invoice.paidarrives with a freshevent_id = Canddata.reason === 'reorg'. This is a restore: re-apply the credit.
The restoring invoice.paid may carry the same tx_hash as the original. If you dedupe on tx_hash, you would drop the restore and the user would stay un-credited after you reversed them on step 2. So:
- Idempotency key =
event_id(unique per delivery). Nevertx_hash. - The reorg-restore signal =
data.reason === 'reorg'on aninvoice.paid. resent_from_event_idis honest redelivery lineage —nullon first-time events, set to the original event'sevent_idfor BOTH operator-triggered resends and reorg re-confirmations. It is not a restore flag; an operator resend (resent_from_event_idset,data.reasonnot'reorg') is just a duplicate delivery, absorbed by yourevent_iddedupe.
import { verifyWebhookSignature } from '@txnod/sdk';
export async function POST(request: Request): Promise<Response> {
const event = verifyWebhookSignature(
request.headers,
await request.text(),
process.env.TXNOD_API_SECRET!,
);
// Dedupe on event_id — the ONLY safe idempotency key. Never tx_hash.
// This also absorbs operator resends (a duplicate of an already-seen event).
if (await alreadyProcessed(event.event_id)) {
return Response.json({ ok: true });
}
if (event.event_type === 'invoice.reverted') {
await reverseCredit(event.data.invoice_id); // undo fulfillment
} else if (event.event_type === 'invoice.paid') {
if (event.data.reason === 'reorg') {
// Restore after a reorg revert. Same invoice (and possibly the same
// tx_hash) — re-apply the credit; do NOT drop it as a duplicate.
await restoreCredit(event.data.invoice_id);
} else {
await fulfillOrder(event.data.invoice_id); // first-time payment
}
}
await markProcessed(event.event_id);
return Response.json({ ok: true });
}
declare function alreadyProcessed(eventId: string): Promise<boolean>;
declare function markProcessed(eventId: string): Promise<void>;
declare function fulfillOrder(invoiceId: string): Promise<void>;
declare function reverseCredit(invoiceId: string): Promise<void>;
declare function restoreCredit(invoiceId: string): Promise<void>;Correlate the revert/restore pair by data.invoice_id. An invoice that goes through the revert/re-confirm cycle legitimately emits invoice.paid more than once — invoice.paid is not a once-per-invoice event.
Polygon PoS — create and verify (usdt_polygon)
Polygon PoS invoices settle after 128 blocks — the Heimdall checkpoint depth at which the SDK marks an invoice.paid event terminal. The snippet below creates a USDT-Polygon invoice and, in the webhook route, matches the inbound invoice.paid event against the invoice id.
import { randomUUID } from 'node:crypto';
import { TxnodClient, verifyWebhookSignature, TxnodError } from '@txnod/sdk';
const client = new TxnodClient({
projectId: process.env.TXNOD_API_KEY_ID!,
apiSecret: process.env.TXNOD_API_SECRET!,
});
const invoice = await client.createInvoice({
amount_usd: 25.0,
coin: 'usdt_polygon',
external_id: randomUUID(),
callback_url: 'https://my-site.com/api/txnod-webhook',
});
export async function POST(request: Request): Promise<Response> {
try {
const event = verifyWebhookSignature(request.headers, await request.text(), process.env.TXNOD_API_SECRET!);
if (event.event_type === 'invoice.paid' && event.data.invoice_id === invoice.id) {
console.log('Polygon USDT payment confirmed after 128 blocks.');
}
return Response.json({ ok: true });
} catch (err) {
if (err instanceof TxnodError) console.error(err.error_code, err.request_id);
throw err;
}
}BNB Smart Chain — create and verify (usdc_bep20)
BNB Smart Chain invoices settle after 15 blocks (BEP-126 fast-finality plus the Binance deposit convention). Note the 18-decimal trap: usdt_bep20 and usdc_bep20 use 18 decimals on-chain, unlike their 6-decimal ETH / Polygon / TRON counterparts — invoice.amount_crypto already reflects this, so render it verbatim rather than re-scaling it.
import { randomUUID } from 'node:crypto';
import { TxnodClient, verifyWebhookSignature, TxnodError } from '@txnod/sdk';
const client = new TxnodClient({
projectId: process.env.TXNOD_API_KEY_ID!,
apiSecret: process.env.TXNOD_API_SECRET!,
});
const invoice = await client.createInvoice({
amount_usd: 25.0,
coin: 'usdc_bep20',
external_id: randomUUID(),
callback_url: 'https://my-site.com/api/txnod-webhook',
});
export async function POST(request: Request): Promise<Response> {
try {
const event = verifyWebhookSignature(request.headers, await request.text(), process.env.TXNOD_API_SECRET!);
if (event.event_type === 'invoice.paid' && event.data.invoice_id === invoice.id) {
console.log('BSC USDC payment confirmed after 15 blocks.');
}
return Response.json({ ok: true });
} catch (err) {
if (err instanceof TxnodError) console.error(err.error_code, err.request_id);
throw err;
}
}Testnet — staging integrations
The kind discriminator ('production' | 'testnet') is fixed at project create time on the dashboard, not per-invoice. Each project is bound to one kind for its entire lifetime; the SDK call site does not change between production and testnet — it is the project credential (projectId / apiSecret) that selects the chain network. To run a staging integration, create a separate testnet-kind project on the dashboard and use that project's secrets in your staging environment.
import { TxnodClient } from '@txnod/sdk';
// Same SDK code path. The kind is implied by the testnet project credential.
const client = new TxnodClient({
projectId: process.env.TXNOD_TESTNET_API_KEY_ID!,
apiSecret: process.env.TXNOD_TESTNET_API_SECRET!,
});
const invoice = await client.createInvoice({
amount_usd: 5.0,
coin: 'btc',
external_id: 'staging-order-1',
callback_url: 'https://staging.example.com/api/txnod-webhook',
});
// The webhook envelope's `mode` field carries 'testnet' for events
// emitted from a testnet-kind project — branch on it if your handler
// processes both staging and production traffic from one webhook URL.The matching operator wallet on the dashboard side must be registered with the testnet kind (testnet-prefix xpub: tpub/vpub/zpub for BTC, tpub for ETH/Polygon/BSC/TRON, addr_test1... stake for Cardano). Cross-kind binding (production wallet → testnet project, or vice versa) is rejected by the server with a typed wallet_kind_mismatch (HTTP 422) error. Address verification on the SDK side accepts each kind's prefixes for the matching project — no flag, no separate setup. Use a testnet faucet to fund the deposit address; everything else (webhooks, finalization thresholds, idempotency) behaves the same as production, just on testnet confirmations.
Operator-side errors your code MUST handle
Two classes of failure are caused by the operator's side, not the partner's. They cannot be auto-retried; surface them as a structured "checkout temporarily unavailable" state and notify the operator out-of-band.
| Class | When | Recovery |
|---|---|---|
| TxnodSubscriptionExpiredError (HTTP 402) | Operator's TxNod subscription is not active — writes (createInvoice, cancelInvoice, attribute) are blocked. Reads keep working | Operator renews via dashboard /billing. Do NOT retry |
| TxnodTronNoActivatedAddressesError (HTTP 422) | TRON-only: operator's address pool has zero activated rows | Operator activates from dashboard. Do NOT retry. .walletId field carries the operator wallet id for deep-link UX |
import {
TxnodClient,
TxnodSubscriptionExpiredError,
TxnodTronNoActivatedAddressesError,
} from '@txnod/sdk';
declare const client: TxnodClient;
export async function POST(_request: Request): Promise<Response> {
try {
await client.createInvoice({
external_id: 'order-1',
coin: 'usdt_trc20',
amount_usd: 9.99,
});
return Response.json({ ok: true });
} catch (err) {
if (err instanceof TxnodSubscriptionExpiredError) {
return Response.json({ error: 'gateway_unavailable_billing' }, { status: 503 });
}
if (err instanceof TxnodTronNoActivatedAddressesError) {
return Response.json(
{ error: 'gateway_unavailable_tron', wallet_id: err.walletId },
{ status: 503 },
);
}
throw err;
}
}Client tuning options
import { TxnodClient } from '@txnod/sdk';
const client = new TxnodClient({
projectId: process.env.TXNOD_API_KEY_ID!,
apiSecret: process.env.TXNOD_API_SECRET!,
// Per-attempt HTTP timeout (default 30 s). Aborts via AbortSignal.timeout —
// protects against a hung upstream that would otherwise stall your request.
requestTimeoutMs: 15_000,
// Hard cap on response body size (default 1 MiB). Over-cap responses are
// surfaced as a typed 502 — the SDK never buffers an unbounded body.
maxResponseBytes: 256 * 1024,
// Per-attempt observer. One entry per HTTP call (success and each retry).
// Errors thrown from the logger are swallowed.
requestLogger: (e) => {
console.log('txnod', e.method, e.path, e.status, `${e.durationMs}ms`, e.requestId);
},
});
console.log(client.lastRequestId);TXNOD_SDK_LOG_LEVEL controls the SDK's internal pino logger (default warn). Set to info/debug for more verbose internals. The logger redacts xpub and apiSecret keys.
Resolved environment — client.environment
client.environment reports the SDK's resolved deployment context as 'production', 'non-production', or 'unknown', after applying the constructor environment option and the TXNOD_ENVIRONMENT / NODE_ENV fallbacks. Read it at startup to fail fast on a misconfigured deploy, or to gate logging verbosity.
import { TxnodClient } from '@txnod/sdk';
const client = new TxnodClient({
projectId: process.env.TXNOD_API_KEY_ID!,
apiSecret: process.env.TXNOD_API_SECRET!,
});
// Fail fast instead of silently signing production requests with a
// non-production wiring.
if (client.environment !== 'production') {
throw new Error(`TxNod: expected production, got ${client.environment}`);
}Idempotent invoice creation — createOrGetInvoice
If your checkout flow may retry the same external_id (network glitch, queue at-least-once delivery), createOrGetInvoice collapses the "create-then-conflict-then-search" pattern into one call:
import { TxnodClient } from '@txnod/sdk';
declare const client: TxnodClient;
const invoice = await client.createOrGetInvoice({
external_id: 'order-42',
coin: 'usdt_trc20',
amount_usd: 9.99,
callback_url: 'https://your-site.com/api/txnod-webhook',
});
// On TxnodExternalIdConflictError, the SDK fetches the pre-existing invoice
// via searchInvoices({ external_id }) and returns it. Same return shape.
console.log(invoice.id);Use the underlying createInvoice if you need the external_id_conflict error to bubble up — for example to distinguish first-create from retried-create in your audit log.
Error handling
Every method on TxnodClient throws a subclass of TxnodError on a non-2xx response. The base class carries error_code, status, request_id, and the raw RFC 7807 envelope as raw. The SDK ships a typed subclass for every server error_code (full table in docs/reference/errors.md, bundled in this tarball); the four most operationally useful are shown below.
import {
TxnodAuthInvalidError,
TxnodError,
TxnodPoolExhaustedError,
TxnodRateLimitError,
TxnodValidationError,
} from '@txnod/sdk';
export async function safeCreateInvoice(create: () => Promise<unknown>): Promise<unknown> {
try {
return await create();
} catch (err) {
if (
err instanceof TxnodRateLimitError ||
err instanceof TxnodPoolExhaustedError
) {
await new Promise((r) => setTimeout(r, err.retry_after_seconds * 1000));
return await create();
}
if (err instanceof TxnodValidationError) {
console.warn('validation failed', err.raw.errors);
throw err;
}
if (err instanceof TxnodAuthInvalidError) {
throw new Error('Re-check TXNOD_API_KEY_ID and TXNOD_API_SECRET.');
}
if (err instanceof TxnodError) {
console.error(err.error_code, err.request_id, err.status);
}
throw err;
}
}The IDE-hover JSDoc on each error class — visible in any TypeScript-aware editor — is the canonical reference. The full error matrix is documented at docs.txnod.com/api.
Links
- Documentation: https://docs.txnod.com/
- API reference: https://docs.txnod.com/api
