Quickstart
This page walks an integrator from a fresh project to a working POST /api/checkout endpoint that creates a TxNod invoice, plus a webhook handler that verifies and dispatches inbound events. The worked example below uses the Next.js 16 App Router for concreteness — the SDK itself is pure Node ≥ 20 and runs unchanged in Express, Fastify, Hono, Koa, NestJS, Nuxt, SvelteKit, Remix, Astro endpoints, or plain node:http (see examples/express-webhook-receiver.md inside the npm tarball for a non-Next.js handler). Every TypeScript block below typechecks against the current @txnod/sdk typings and is exercised by CI.
Track 1 — Sandbox (default, 5 min, agent-driven)
Sandbox projects work end-to-end without on-chain interaction: create one in the dashboard, drive client.sandbox.simulate* to walk the state machine deterministically, and your webhook handler receives real signed events with mode: 'sandbox'. No wallet wizard, no Ledger, no faucet.
Want to hand the integration to an AI coding agent? Read Agent-driven testing — the sandbox surface is designed for an integrate → exercise → verify loop driven by an agent against deterministic state transitions.
1. Create a sandbox project
In the dashboard, click + New sandbox project at https://txnod.com/projects/new-sandbox . One click provisions the project shell, seven testnet xpubs, the chain bindings, and a sandbox PAT scoped to sandbox:simulate. Copy the four secrets shown on the success screen:
# .env.local
TXNOD_PROJECT_ID=01J000000000000000000000000
TXNOD_API_SECRET=sk_sandbox_<...>
TXNOD_WEBHOOK_SECRET=<sandbox project's webhook secret>
TXNOD_PAT=<sandbox PAT with sandbox:simulate scope>Each secret is shown exactly once. Re-issue from the dashboard if any value is lost.
2. Install and instantiate the client
npm install @txnod/sdk@latest// src/lib/txnod.ts
import { TxnodClient } from '@txnod/sdk';
export const txnod = new TxnodClient({
projectId: process.env.TXNOD_PROJECT_ID!,
apiSecret: process.env.TXNOD_API_SECRET!, // sk_sandbox_...
environment: 'non-production', // explicit; trusts NODE_ENV otherwise
});The SDK’s environment detection refuses to instantiate TxnodClient with a sandbox secret in a production-detected env — see Sandbox safety for the seven-layer defense.
3. Create a sandbox invoice
import { TxnodClient } from '@txnod/sdk';
declare const txnod: TxnodClient;
const invoice = await txnod.createInvoice({
amount_usd: 9.99,
coin: 'usdt_trc20',
external_id: 'order-42',
callback_url: 'https://your-site.com/api/txnod-webhook',
});4. Drive the state machine + verify webhooks
import { TxnodClient } from '@txnod/sdk';
declare const txnod: TxnodClient;
declare const invoice: { id: string };
await txnod.sandbox.simulateDetect(invoice.id, { seed: 'order-42' });
await txnod.sandbox.simulatePaid(invoice.id);
// Your webhook handler receives invoice.detected then invoice.paid with mode='sandbox'The webhook handler asserts event.mode === 'sandbox' so a misrouted production envelope cannot pass.
5. Wire the webhook handler
Two route handlers wire the full sandbox loop. POST /api/checkout mints invoices; POST /api/txnod-webhook receives signed events back. Both fit on one screen each. The shape uses Web-standard Request/Response so it ports verbatim into Next.js App Router, Hono, plain node:http (via Request.from(req)), and any Web-Fetch-API-compatible runtime; for Express, replace request.text() with req.rawBody.toString('utf8') after express.raw().
import { TxnodClient } from '@txnod/sdk';
declare const txnod: TxnodClient;
export async function checkout(request: Request): Promise<Response> {
const { externalId, amountUsd } = (await request.json()) as {
externalId: string;
amountUsd: number;
};
const invoice = await txnod.createInvoice({
external_id: externalId,
amount_usd: amountUsd,
coin: 'usdt_trc20',
callback_url: `${process.env['SITE_URL']!}/api/txnod-webhook`,
});
return Response.json({
invoiceId: invoice.id,
paymentUri: invoice.payment_uri,
});
}import {
verifyWebhookSignature,
TxnodHmacError,
TxnodTimestampError,
} from '@txnod/sdk';
const seenEventIds = new Set<string>();
export async function webhook(request: Request): Promise<Response> {
const rawBody = await request.text();
try {
const event = verifyWebhookSignature(
request.headers,
rawBody,
process.env['TXNOD_WEBHOOK_SECRET']!,
);
if (event.mode !== 'sandbox') {
return Response.json({ error: 'unexpected mode' }, { status: 400 });
}
if (seenEventIds.has(event.event_id)) {
return Response.json({ ok: true });
}
seenEventIds.add(event.event_id);
if (event.event_type === 'invoice.paid') {
// event.data.invoice_id is fully typed inside the narrowed branch
// fulfil the order; the dedupe set above ensures retries are no-ops
}
return Response.json({ ok: true });
} catch (err) {
if (err instanceof TxnodHmacError || err instanceof TxnodTimestampError) {
return Response.json({ error: 'signature' }, { status: 401 });
}
throw err;
}
}The handler dedups on event.event_id (stable across retries and reorg-replays). Branch on event.event_type for narrowed event.data types per the discriminated union. Before you ship to mainnet, read Sandbox safety.
Cross-links: Sandbox projects · Agent-driven testing · Sandbox safety.
Track 2 — Mainnet / testnet
Use this flow after a hardware-wallet xpub is registered (Wallets wizard).
Install
npm install @txnod/sdk
pnpm add @txnod/sdk
yarn add @txnod/sdk
bun add @txnod/sdkNode ≥ 20 is required. The SDK ships zero runtime and zero peer dependencies — types are fully expanded inside the published tarball, so you do not install zod, @txnod/shared, or any other companion package.
AI coding agents: after install, prefer
node_modules/@txnod/sdk/docs/andnode_modules/@txnod/sdk/AGENTS.mdover this website. The bundled markdown is the same content, version-matched to the installed SDK, and needs no network access.
Environment
# .env.local
TXNOD_PROJECT_ID=01J000000000000000000000000
TXNOD_API_SECRET=<64-hex-character API secret>
TXNOD_WEBHOOK_SECRET=<same as TXNOD_API_SECRET>TXNOD_WEBHOOK_SECRET is the same value as TXNOD_API_SECRET; the dashboard surfaces both names so you can wire two distinct env vars without re-deriving the relationship.
Create an invoice
A complete app/api/checkout/route.ts route handler. The client auto-signs every outbound request with HMAC-SHA256, throws typed errors on a non-2xx response, and retries automatically on 429 and 5xx.
import { randomUUID } from 'node:crypto';
import { TxnodClient, TxnodCoinNotEnabledError, TxnodError } from '@txnod/sdk';
const client = new TxnodClient({
projectId: process.env.TXNOD_PROJECT_ID!,
apiSecret: process.env.TXNOD_API_SECRET!,
});
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;
}
}Specific subclasses of TxnodError (e.g. TxnodCoinNotEnabledError) narrow on instanceof first; the base TxnodError catches every other server-side error. Anything that isn’t a TxnodError is re-thrown so the framework can log it.
Verify a webhook
A complete app/api/txnod-webhook/route.ts that reads the raw body, verifies the HMAC, narrows by event.event_type, and handles invoice.paid:
import {
verifyWebhookSignature,
TxnodHmacError,
TxnodSignatureFormatError,
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_WEBHOOK_SECRET!,
);
if (event.event_type === 'invoice.paid') {
const invoiceId = event.data['invoice_id'] as string;
console.log('paid invoice', invoiceId, 'at', event.created_at_iso);
}
return Response.json({ ok: true });
} catch (err) {
if (err instanceof TxnodSignatureFormatError) {
return new Response('bad signature format', { status: 401 });
}
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) parses the single combined X-Txnod-Signature: t=<unix>,v1=<hex> header, recomputes HMAC-SHA256 over ${timestamp}.${rawBody} using the project secret, compares in constant time, and enforces a ±300-second timestamp window. Read the raw body before parsing JSON — Next.js’s request.json() consumes the stream and makes HMAC verification impossible.
Typed error handling
Every TxnodClient method throws a subclass of TxnodError on a non-2xx response. Catch specific subclasses first, then fall through to the base class:
import {
TxnodClient,
TxnodCoinNotEnabledError,
TxnodError,
TxnodPoolExhaustedError,
TxnodRateLimitError,
} from '@txnod/sdk';
const client = new TxnodClient({
projectId: process.env.TXNOD_PROJECT_ID!,
apiSecret: process.env.TXNOD_API_SECRET!,
});
export async function safeCreateInvoice(): Promise<unknown> {
try {
return await client.createInvoice({
amount_usd: 10,
coin: 'btc',
external_id: 'order-42',
});
} catch (err) {
if (err instanceof TxnodCoinNotEnabledError) {
const rates = await client.getRates({});
return { error: 'coin_not_enabled', enabled: Object.keys(rates.rates) };
}
if (
err instanceof TxnodRateLimitError ||
err instanceof TxnodPoolExhaustedError
) {
await new Promise((r) => setTimeout(r, err.retry_after_seconds * 1000));
return client.createInvoice({
amount_usd: 10,
coin: 'btc',
external_id: 'order-42',
});
}
if (err instanceof TxnodError) {
console.error(err.error_code, err.status, err.request_id);
}
throw err;
}
}Switching to testnet
The SDK does not carry a network flag on createInvoice. The kind discriminator ('production' | 'testnet') is fixed when the project is created on the dashboard, not per-invoice. To run a staging integration, create a separate testnet-kind project on the dashboard and point your staging environment at that project’s secrets — the matching operator wallet must be registered with the same testnet kind (testnet-prefix xpub: tpub/vpub/zpub for BTC, tpub for ETH/Polygon/BSC/TRON, addr_test1... stake for Cardano). The HMAC scheme, the response envelope, and every typed error stay identical; only the underlying chain network changes (Bitcoin signet/testnet, Ethereum Sepolia, Polygon Amoy, BSC testnet, TRON Shasta/Nile, Cardano preprod/preview). Cross-kind binding (production wallet → testnet project, or vice versa) is rejected with a typed wallet_kind_mismatch (HTTP 422) error. See Project kinds for the wider model.
import { TxnodClient } from '@txnod/sdk';
// Same SDK code path as production. Point the credential at a testnet-kind project.
const client = new TxnodClient({
projectId: process.env.TXNOD_TESTNET_PROJECT_ID!,
apiSecret: process.env.TXNOD_TESTNET_API_SECRET!,
});
const invoice = await client.createInvoice({
amount_usd: 5,
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.Use a public testnet faucet to fund the deposit address. Webhooks, idempotency, and finalization thresholds behave the same as production — just on testnet confirmations.
Next steps
- Every endpoint is documented in the API Reference.
- The full client surface is in the SDK Reference.
- Inbound webhook event shapes are in Webhook Event Types.
- Day-two guides: API keys, Wallets, Configuring partner webhooks.