Skip to Content
Quickstart

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/sdk

Node ≥ 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/ and node_modules/@txnod/sdk/AGENTS.md over 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