# TxNod documentation — concatenated reference > TxNod is a non-custodial crypto payment gateway. The TypeScript SDK is pure Node ≥ 20 and runs in any server-side framework (Express, Fastify, Hono, Koa, NestJS, plain `node:http`, Next.js App Router or Pages, Nuxt, SvelteKit, Remix, Astro endpoints, …). It supports BTC; ETH + ERC-20 (USDT/USDC); POL + Polygon USDT/USDC; BNB + BEP-20 USDT/USDC; TRX + TRC-20 USDT; ADA; and TON + jetton USDT — fifteen assets across seven chains. This file concatenates the full text of the key top-level guides — quickstart, API reference summary, SDK reference, and the webhook guide — so an AI coding agent can ingest the agent-critical surface in a single fetch. For the full per-page index (including the Guides section), see `llms.txt` and follow the `.md` links for selective per-page retrieval. --- ## Quickstart — Get from zero to a verified testnet invoice. Source: # 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`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) inside the npm tarball for a non-Next.js handler). Every TypeScript block below typechecks against the current [@txnod/sdk](https://www.npmjs.com/package/@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](./guides/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](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: ```bash # .env.local TXNOD_PROJECT_ID=01J000000000000000000000000 TXNOD_API_SECRET=sk_sandbox_<...> TXNOD_WEBHOOK_SECRET= TXNOD_PAT= ``` Each secret is shown exactly once. Re-issue from the dashboard if any value is lost. ### 2. Install and instantiate the client ```bash npm install @txnod/sdk@latest ``` ```ts // 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](./guides/sandbox-safety) for the seven-layer defense. ### 3. Create a sandbox invoice ```ts 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 ```ts 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()`. ```ts import { TxnodClient } from '@txnod/sdk'; declare const txnod: TxnodClient; export async function checkout(request: Request): Promise { 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, }); } ``` ```ts import { verifyWebhookSignature, TxnodHmacError, TxnodTimestampError, } from '@txnod/sdk'; const seenEventIds = new Set(); export async function webhook(request: Request): Promise { 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](./guides/sandbox-safety).** Cross-links: [Sandbox projects](./guides/sandbox-projects) · [Agent-driven testing](./guides/agent-driven-testing) · [Sandbox safety](./guides/sandbox-safety). ## Track 2 — Mainnet / testnet Use this flow after a hardware-wallet xpub is registered (Wallets wizard). ### Install ```bash 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 ```bash # .env.local TXNOD_PROJECT_ID=01J000000000000000000000000 TXNOD_API_SECRET=<64-hex-character API secret> TXNOD_WEBHOOK_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. ```ts 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 { 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`: ```ts import { verifyWebhookSignature, TxnodHmacError, TxnodSignatureFormatError, TxnodTimestampError, } from '@txnod/sdk'; export async function POST(request: Request): Promise { 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=,v1=` 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: ```ts 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 { 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](/guides/project-kinds) for the wider model. ```ts 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](/api/invoices). - The full client surface is in the [SDK Reference](/sdk/client). - Inbound webhook event shapes are in [Webhook Event Types](/webhooks/events). - Day-two guides: [API keys](/guides/api-keys), [Wallets](/guides/wallets), [Configuring partner webhooks](/guides/webhooks). --- ## Invoices — Create, retrieve, search, and cancel invoices. Source: # Invoices Base URL: `https://txnod.com/api/v1`. Every request must carry the three HMAC headers: - `X-Project-Id` — the caller's project ULID. - `X-Timestamp` — current Unix timestamp (seconds). Requests outside the ±300-second window are rejected with `timestamp_out_of_window`. - `X-Signature` — HMAC-SHA256 hex digest of `${timestamp}.${rawBody}` using the project's API secret. The SDK (`@txnod/sdk`) handles signing transparently — prefer it over hand-rolled cURL except for smoke tests. ## Create an invoice `POST https://txnod.com/api/v1/invoices` — creates a new invoice. Idempotent on `(project_id, external_id)`: a replayed request returns the existing invoice with HTTP 200 instead of 201. ### Request {/* @schema:createInvoice:request */} **Request body** (`application/json`) | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `external_id` | string | yes | min length: 1; max length: 128 | | `amount_usd` | number | no | exclusive min: 0 | | `amount_crypto` | string | no | pattern: `^\d+(\.\d+)?$` | | `coin` | `"btc"` \| `"eth"` \| `"usdt_erc20"` \| `"usdc_erc20"` \| `"trx"` \| `"usdt_trc20"` \| `"ada"` \| `"pol"` \| `"usdt_polygon"` \| `"usdc_polygon"` \| `"bnb"` \| `"usdt_bep20"` \| `"usdc_bep20"` \| `"ton"` \| `"usdt_ton"` | yes | — | | `callback_url` | string (URI) | no | — | | `metadata` | object | no | — | {/* @end:schema:createInvoice:request */} ### Response {/* @schema:createInvoice:response */} **200** — Idempotent replay — existing invoice returned unchanged | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `id` | string (ULID) | yes | — | | `project_id` | string (ULID) | yes | — | | `external_id` | string | yes | — | | `coin` | `"btc"` \| `"eth"` \| `"usdt_erc20"` \| `"usdc_erc20"` \| `"trx"` \| `"usdt_trc20"` \| `"ada"` \| `"pol"` \| `"usdt_polygon"` \| `"usdc_polygon"` \| `"bnb"` \| `"usdt_bep20"` \| `"usdc_bep20"` \| `"ton"` \| `"usdt_ton"` | yes | — | | `address` | string | yes | — | | `amount_crypto` | string | yes | — | | `amount_crypto_units` | string | yes | — | | `amount_usd` | number \| null | yes | — | | `rate_snapshot` | object \| null | yes | — | | `payment_token` | string \| null | yes | pattern: `^[0-9a-f]{8}$` | | `payment_uri` | string | yes | — | | `callback_url` | string \| null | yes | — | | `metadata` | object \| null | yes | — | | `matching_mode` | `"exact"` \| `"at_least"` \| `"any"` | yes | — | | `confirmation_threshold` | integer | yes | min: 0 | | `status` | `"pending"` \| `"detected"` \| `"paid"` \| `"overpaid"` \| `"partial"` \| `"expired"` \| `"expired_paid_late"` \| `"reverted"` \| `"cancelled"` | yes | — | | `expires_at` | integer | yes | min: 0 | | `expires_at_iso` | string (ISO 8601) | yes | — | | `created_at` | integer | yes | min: 0 | | `created_at_iso` | string (ISO 8601) | yes | — | | `derivation_path` | string | no | pattern: `^m(\/\d+'?)+$` | | `verification_standard` | `"bip84"` \| `"bip44"` \| `"cip1852"` \| `"bip44_ed25519"` | no | — | | `transactions` | array<object> | no | — | | `confirmations` | integer | no | min: 0 | **201** — Invoice created | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `id` | string (ULID) | yes | — | | `project_id` | string (ULID) | yes | — | | `external_id` | string | yes | — | | `coin` | `"btc"` \| `"eth"` \| `"usdt_erc20"` \| `"usdc_erc20"` \| `"trx"` \| `"usdt_trc20"` \| `"ada"` \| `"pol"` \| `"usdt_polygon"` \| `"usdc_polygon"` \| `"bnb"` \| `"usdt_bep20"` \| `"usdc_bep20"` \| `"ton"` \| `"usdt_ton"` | yes | — | | `address` | string | yes | — | | `amount_crypto` | string | yes | — | | `amount_crypto_units` | string | yes | — | | `amount_usd` | number \| null | yes | — | | `rate_snapshot` | object \| null | yes | — | | `payment_token` | string \| null | yes | pattern: `^[0-9a-f]{8}$` | | `payment_uri` | string | yes | — | | `callback_url` | string \| null | yes | — | | `metadata` | object \| null | yes | — | | `matching_mode` | `"exact"` \| `"at_least"` \| `"any"` | yes | — | | `confirmation_threshold` | integer | yes | min: 0 | | `status` | `"pending"` \| `"detected"` \| `"paid"` \| `"overpaid"` \| `"partial"` \| `"expired"` \| `"expired_paid_late"` \| `"reverted"` \| `"cancelled"` | yes | — | | `expires_at` | integer | yes | min: 0 | | `expires_at_iso` | string (ISO 8601) | yes | — | | `created_at` | integer | yes | min: 0 | | `created_at_iso` | string (ISO 8601) | yes | — | | `derivation_path` | string | no | pattern: `^m(\/\d+'?)+$` | | `verification_standard` | `"bip84"` \| `"bip44"` \| `"cip1852"` \| `"bip44_ed25519"` | no | — | | `transactions` | array<object> | no | — | | `confirmations` | integer | no | min: 0 | {/* @end:schema:createInvoice:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 400 | `validation_error`, `invalid_coin`, `invalid_xpub_format`, `invalid_webhook_url` | Request body or query failed validation | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | | 402 | `subscription_expired` | Operator's TxNod subscription is not `active`; writes are blocked. Operator must renew via dashboard `/billing` | | 403 | `key_revoked`, `key_suspended`, `project_suspended`, `permission_denied` | Auth key or project disabled by operator action | | 409 | `external_id_conflict`, `xpub_not_verified` | Idempotent replay or ownership challenge incomplete | | 422 | `coin_not_enabled`, `amount_out_of_range`, `wallet_not_bound`, `tron_no_activated_addresses_available` | Coin not enabled, amount out of range, no verified wallet bound for the requested chain on the project, or — TRON only — operator's address pool has zero activated rows | | 429 | `rate_limit_exceeded` | Per-project invoice-create rate limit exceeded | | 500 | `internal_error` | Internal error (including cold-start rate unavailability) | | 503 | `pool_exhausted` | Address pool at hard cap; retry after `Retry-After` | ### Examples ```bash curl -X POST https://txnod.com/api/v1/invoices \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " \ -H "Content-Type: application/json" \ -d '{"external_id":"order-123","amount_usd":10.0,"coin":"btc"}' ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const invoice = await client.createInvoice({ external_id: 'order-123', amount_usd: 10.0, coin: 'btc', }); ``` ## Search invoices `GET https://txnod.com/api/v1/invoices` — cursor-paginated search over the caller project's invoices. ### Request {/* @schema:searchInvoices:request */} **Query parameters** | Param | Type | Required | Constraints | | --- | --- | --- | --- | | `external_id` | string | no | — | | `address` | string | no | — | | `tx_hash` | string | no | — | | `amount` | string | no | — | | `status` | `"pending"` \| `"detected"` \| `"paid"` \| `"overpaid"` \| `"partial"` \| `"expired"` \| `"expired_paid_late"` \| `"reverted"` \| `"cancelled"` | no | — | | `date_from` | string (ISO 8601) | no | — | | `date_to` | string (ISO 8601) | no | — | | `cursor` | string (ULID) | no | — | | `limit` | integer | no | min: 1; max: 200; default: `50` | {/* @end:schema:searchInvoices:request */} ### Response {/* @schema:searchInvoices:response */} **200** — Cursor-paginated invoice list | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `items` | array<object> | yes | — | | `next_cursor` | string (ULID) | no | — | {/* @end:schema:searchInvoices:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 400 | `validation_error` | Query validation error | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | ### Examples ```bash curl "https://txnod.com/api/v1/invoices?status=paid&limit=20" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const page = await client.searchInvoices({ status: 'paid', limit: 20 }); for (const invoice of page.items) console.log(invoice.id); ``` ## Retrieve an invoice `GET https://txnod.com/api/v1/invoices/{id}` — fetch a single invoice by ULID. Returns 404 on cross-project lookups (no enumeration). ### Request {/* @schema:getInvoice:request */} **Path parameters** | Param | Type | Required | Constraints | | --- | --- | --- | --- | | `id` | string (ULID) | yes | — | {/* @end:schema:getInvoice:request */} ### Response {/* @schema:getInvoice:response */} **200** — Invoice detail including transactions + confirmations | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `id` | string (ULID) | yes | — | | `project_id` | string (ULID) | yes | — | | `external_id` | string | yes | — | | `coin` | `"btc"` \| `"eth"` \| `"usdt_erc20"` \| `"usdc_erc20"` \| `"trx"` \| `"usdt_trc20"` \| `"ada"` \| `"pol"` \| `"usdt_polygon"` \| `"usdc_polygon"` \| `"bnb"` \| `"usdt_bep20"` \| `"usdc_bep20"` \| `"ton"` \| `"usdt_ton"` | yes | — | | `address` | string | yes | — | | `amount_crypto` | string | yes | — | | `amount_crypto_units` | string | yes | — | | `amount_usd` | number \| null | yes | — | | `rate_snapshot` | object \| null | yes | — | | `payment_token` | string \| null | yes | pattern: `^[0-9a-f]{8}$` | | `payment_uri` | string | yes | — | | `callback_url` | string \| null | yes | — | | `metadata` | object \| null | yes | — | | `matching_mode` | `"exact"` \| `"at_least"` \| `"any"` | yes | — | | `confirmation_threshold` | integer | yes | min: 0 | | `status` | `"pending"` \| `"detected"` \| `"paid"` \| `"overpaid"` \| `"partial"` \| `"expired"` \| `"expired_paid_late"` \| `"reverted"` \| `"cancelled"` | yes | — | | `expires_at` | integer | yes | min: 0 | | `expires_at_iso` | string (ISO 8601) | yes | — | | `created_at` | integer | yes | min: 0 | | `created_at_iso` | string (ISO 8601) | yes | — | | `derivation_path` | string | no | pattern: `^m(\/\d+'?)+$` | | `verification_standard` | `"bip84"` \| `"bip44"` \| `"cip1852"` \| `"bip44_ed25519"` | no | — | | `transactions` | array<object> | no | — | | `confirmations` | integer | no | min: 0 | {/* @end:schema:getInvoice:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | | 404 | `invoice_not_found` | Invoice not found or belongs to a different project | ### Examples ```bash curl https://txnod.com/api/v1/invoices/01HK8MAR2QEXAMPLE000000000 \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const invoice = await client.getInvoice('01HK8MAR2QEXAMPLE000000000'); console.log(invoice.status); ``` ## Cancel an invoice `POST https://txnod.com/api/v1/invoices/{id}/cancel` — cancels an invoice in `pending` or `detected` state and releases its pool address into cooldown. Terminal-status invoices return 409 `invoice_not_cancellable`. ### Request {/* @schema:cancelInvoice:request */} **Path parameters** | Param | Type | Required | Constraints | | --- | --- | --- | --- | | `id` | string (ULID) | yes | — | {/* @end:schema:cancelInvoice:request */} ### Response {/* @schema:cancelInvoice:response */} **200** — Invoice cancelled | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `id` | string (ULID) | yes | — | | `project_id` | string (ULID) | yes | — | | `external_id` | string | yes | — | | `coin` | `"btc"` \| `"eth"` \| `"usdt_erc20"` \| `"usdc_erc20"` \| `"trx"` \| `"usdt_trc20"` \| `"ada"` \| `"pol"` \| `"usdt_polygon"` \| `"usdc_polygon"` \| `"bnb"` \| `"usdt_bep20"` \| `"usdc_bep20"` \| `"ton"` \| `"usdt_ton"` | yes | — | | `address` | string | yes | — | | `amount_crypto` | string | yes | — | | `amount_crypto_units` | string | yes | — | | `amount_usd` | number \| null | yes | — | | `rate_snapshot` | object \| null | yes | — | | `payment_token` | string \| null | yes | pattern: `^[0-9a-f]{8}$` | | `payment_uri` | string | yes | — | | `callback_url` | string \| null | yes | — | | `metadata` | object \| null | yes | — | | `matching_mode` | `"exact"` \| `"at_least"` \| `"any"` | yes | — | | `confirmation_threshold` | integer | yes | min: 0 | | `status` | `"pending"` \| `"detected"` \| `"paid"` \| `"overpaid"` \| `"partial"` \| `"expired"` \| `"expired_paid_late"` \| `"reverted"` \| `"cancelled"` | yes | — | | `expires_at` | integer | yes | min: 0 | | `expires_at_iso` | string (ISO 8601) | yes | — | | `created_at` | integer | yes | min: 0 | | `created_at_iso` | string (ISO 8601) | yes | — | | `derivation_path` | string | no | pattern: `^m(\/\d+'?)+$` | | `verification_standard` | `"bip84"` \| `"bip44"` \| `"cip1852"` \| `"bip44_ed25519"` | no | — | | `transactions` | array<object> | no | — | | `confirmations` | integer | no | min: 0 | {/* @end:schema:cancelInvoice:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | | 404 | `invoice_not_found` | Invoice not found or belongs to a different project | | 409 | `invoice_not_cancellable`, `invalid_state_transition` | Invoice is not in a cancellable state | ### Examples ```bash curl -X POST https://txnod.com/api/v1/invoices/01HK8MAR2QEXAMPLE000000000/cancel \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const cancelled = await client.cancelInvoice('01HK8MAR2QEXAMPLE000000000'); console.log(cancelled.status); ``` --- ## Orphan Payments — List and attribute unmatched transactions. Source: # Orphan Payments Base URL: `https://txnod.com/api/v1`. Same HMAC header requirements as the [Invoices](/api/invoices) API: `X-Project-Id`, `X-Timestamp`, `X-Signature`. An orphan payment is an on-chain receipt that could not be matched to any open invoice address at detection time. Common causes are manual sends to a reused address, invoice expiry before funds arrived, or partners that do not use `callback_url`. Orphans are surfaced so operators can resolve them by attributing to an `external_id` — the attribution API creates a synthetic invoice in `paid` state indistinguishable from the normal-payment path. ## List orphan payments `GET https://txnod.com/api/v1/orphan-payments` — cursor-paginated list for the authenticated project. Filters by attribution status, chain, tx hash, date range, and amount range. ### Request {/* @schema:listOrphanPayments:request */} **Query parameters** | Param | Type | Required | Constraints | | --- | --- | --- | --- | | `attributed` | `"true"` \| `"false"` | no | — | | `chain` | `"btc"` \| `"eth"` \| `"tron"` \| `"ada"` \| `"polygon"` \| `"bsc"` \| `"ton"` | no | — | | `tx_hash` | string | no | — | | `date_from` | string (ISO 8601) | no | — | | `date_to` | string (ISO 8601) | no | — | | `amount_units_gte` | string | no | pattern: `^\d+$` | | `amount_units_lte` | string | no | pattern: `^\d+$` | | `cursor` | string (ULID) | no | — | | `limit` | integer | no | min: 1; max: 200; default: `50` | {/* @end:schema:listOrphanPayments:request */} ### Response {/* @schema:listOrphanPayments:response */} **200** — Cursor-paginated orphan-payment list | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `items` | array<object> | yes | — | | `next_cursor` | string (ULID) | no | — | {/* @end:schema:listOrphanPayments:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 400 | `validation_error` | Query validation error | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | | 429 | `rate_limit_exceeded` | Per-project rate limit exceeded | | 500 | `internal_error` | Internal error | ### Examples ```bash curl "https://txnod.com/api/v1/orphan-payments?chain=btc&attributed=false&limit=50" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const page = await client.listOrphanPayments({ chain: 'btc', limit: 50 }); for (const orphan of page.items) console.log(orphan.tx_hash); ``` ## Resolve an orphan from the dashboard If you don't want to call the attribution endpoint directly, resolve orphans from the dashboard: 1. Open **Orphan Payments** in the dashboard. Filter by `attributed=false` and the relevant chain. 2. Identify the orphan row by matching `tx_hash`, `to_address`, `amount_units`, or `received_at` against your ledger to find the corresponding `external_id`. 3. Click **Attribute** on the row, enter the target `external_id`, and optionally a `user_id` / `metadata` blob for the synthetic invoice. 4. The attribution creates a synthetic invoice in `paid` status and enqueues an `invoice.paid` webhook event indistinguishable from the real-payment path. Your normal handler processes it without any special-case code. If the `external_id` is already in use for a different invoice, the dashboard surfaces `external_id_conflict`. If the orphan was already attributed, it surfaces `orphan_already_attributed`. Both are idempotent — they identify the collision cleanly and cannot cause double-fulfillment. ## Attribute an orphan payment `POST https://txnod.com/api/v1/orphan-payments/{tx_hash}/attribute` — attributes a previously unmatched payment to the caller-supplied `external_id`. Creates a synthetic invoice in `paid` status and enqueues an `invoice.paid` webhook event indistinguishable from the real-payment path. Idempotent replay with the same `external_id` collides via `external_id_conflict`. Re-attribution of an already-attributed orphan returns `orphan_already_attributed`. ### Request {/* @schema:attributeOrphanPayment:request */} **Request body** (`application/json`) | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `external_id` | string | yes | min length: 1; max length: 128 | | `user_id` | string | no | min length: 1; max length: 256 | | `metadata` | object | no | — | | `to_address` | string | no | min length: 1 | | `tx_output_index` | integer | no | min: 0 | {/* @end:schema:attributeOrphanPayment:request */} ### Response {/* @schema:attributeOrphanPayment:response */} **200** — Orphan attributed; synthetic invoice returned | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `id` | string (ULID) | yes | — | | `project_id` | string (ULID) | yes | — | | `external_id` | string | yes | — | | `coin` | `"btc"` \| `"eth"` \| `"usdt_erc20"` \| `"usdc_erc20"` \| `"trx"` \| `"usdt_trc20"` \| `"ada"` \| `"pol"` \| `"usdt_polygon"` \| `"usdc_polygon"` \| `"bnb"` \| `"usdt_bep20"` \| `"usdc_bep20"` \| `"ton"` \| `"usdt_ton"` | yes | — | | `address` | string | yes | — | | `amount_crypto` | string | yes | — | | `amount_crypto_units` | string | yes | — | | `amount_usd` | number \| null | yes | — | | `rate_snapshot` | object \| null | yes | — | | `payment_token` | string \| null | yes | pattern: `^[0-9a-f]{8}$` | | `payment_uri` | string | yes | — | | `callback_url` | string \| null | yes | — | | `metadata` | object \| null | yes | — | | `matching_mode` | `"exact"` \| `"at_least"` \| `"any"` | yes | — | | `confirmation_threshold` | integer | yes | min: 0 | | `status` | `"pending"` \| `"detected"` \| `"paid"` \| `"overpaid"` \| `"partial"` \| `"expired"` \| `"expired_paid_late"` \| `"reverted"` \| `"cancelled"` | yes | — | | `expires_at` | integer | yes | min: 0 | | `expires_at_iso` | string (ISO 8601) | yes | — | | `created_at` | integer | yes | min: 0 | | `created_at_iso` | string (ISO 8601) | yes | — | | `derivation_path` | string | no | pattern: `^m(\/\d+'?)+$` | | `verification_standard` | `"bip84"` \| `"bip44"` \| `"cip1852"` \| `"bip44_ed25519"` | no | — | | `transactions` | array<object> | no | — | | `confirmations` | integer | no | min: 0 | {/* @end:schema:attributeOrphanPayment:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 400 | `validation_error` | Validation error (e.g. ambiguous tx_hash without disambiguators) | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | | 404 | `orphan_not_found` | Orphan not found or belongs to a different project | | 409 | `external_id_conflict`, `orphan_already_attributed` | `external_id_conflict` (external_id already in use) or `orphan_already_attributed` (orphan already attributed) | | 429 | `rate_limit_exceeded` | Per-project rate limit exceeded | | 500 | `internal_error` | Internal error (e.g. rate quote unavailable) | ### Examples ```bash curl -X POST https://txnod.com/api/v1/orphan-payments/0xabc123/attribute \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " \ -H "Content-Type: application/json" \ -d '{"external_id":"order-42"}' ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const invoice = await client.attributeOrphanPayment('0xabc123', { external_id: 'order-42', }); console.log(invoice.id, invoice.status); ``` --- ## Inbound Webhooks — Partner-configured webhook subscription management. Source: # Webhooks API Base URL: `https://txnod.com/api/v1`. Same HMAC header requirements as the [Invoices](/api/invoices) API: `X-Project-Id`, `X-Timestamp`, `X-Signature`. This API exposes the delivery log and resend control surface for **outbound** webhook events your project receives from TxNod. To learn which event types exist and what shapes they carry, see [Webhook Event Types](/webhooks/events). To configure a project's webhook URL or rotate its API secret, see [Configuring partner webhooks](/guides/webhooks) and [API keys](/guides/api-keys). ## List webhook events `GET https://txnod.com/api/v1/webhooks/events` — cursor-paginated list of outbound webhook events for the authenticated project. `projectId` is derived from HMAC; filters (`status`, `event_type`, `since`, `invoice_id`) are optional snake_case query parameters. The response envelope uses canonical `next_cursor` (snake_case, omitted on the last page). ### Request {/* @schema:listWebhookEvents:request */} **Query parameters** | Param | Type | Required | Constraints | | --- | --- | --- | --- | | `status` | `"delivered"` \| `"retrying"` \| `"dlq"` \| `"skipped"` | no | — | | `event_type` | `"invoice.detected"` \| `"invoice.paid"` \| `"invoice.overpaid"` \| `"invoice.partial"` \| `"invoice.expired"` \| `"invoice.expired_paid_late"` \| `"invoice.reverted"` | no | — | | `since` | string (ISO 8601) | no | — | | `invoice_id` | string (ULID) | no | — | | `cursor` | string (ULID) | no | — | | `limit` | integer | no | min: 1; max: 200; default: `50` | {/* @end:schema:listWebhookEvents:request */} ### Response {/* @schema:listWebhookEvents:response */} **200** — Cursor-paginated webhook event list | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `items` | array<object> | yes | — | | `next_cursor` | string (ULID) | no | — | {/* @end:schema:listWebhookEvents:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 400 | `validation_error` | Query validation error | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | | 403 | `key_revoked`, `project_suspended` | API key revoked or project suspended | | 500 | `internal_error` | Internal error | ### Examples ```bash curl "https://txnod.com/api/v1/webhooks/events?status=delivered&limit=100" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const page = await client.listWebhookEvents({ status: 'delivered', limit: 100 }); for (const event of page.items) console.log(event.id, event.status); ``` ## Resend a webhook event `POST https://txnod.com/api/v1/webhooks/events/{event_id}/resend` — resends a previously-emitted webhook event with a fresh `event_id` while preserving the original payload. `target_url` is re-resolved at resend time from `invoices.callback_url` (if any) or `projects.default_webhook_url`. Delivery is asynchronous — the row is enqueued onto the outbox and dispatched by the worker; the HTTP response is 202 Accepted. Resend is allowed for delivered, retrying, and DLQ'd events alike; the original row is never mutated. ### Request {/* @schema:resendWebhookEvent:request */} _(no request parameters)_ {/* @end:schema:resendWebhookEvent:request */} ### Response {/* @schema:resendWebhookEvent:response */} **202** — Resend accepted; new event enqueued | Field | Type | Required | Constraints | | --- | --- | --- | --- | | `event_id` | string (ULID) | yes | — | | `original_event_id` | string (ULID) | yes | — | | `event_type` | `"invoice.detected"` \| `"invoice.paid"` \| `"invoice.overpaid"` \| `"invoice.partial"` \| `"invoice.expired"` \| `"invoice.expired_paid_late"` \| `"invoice.reverted"` | yes | — | | `project_id` | string (ULID) | yes | — | | `invoice_id` | string \| null | yes | — | | `target_url` | string \| null | yes | — | | `created_at` | integer | yes | min: 0 | | `created_at_iso` | string (ISO 8601) | yes | — | {/* @end:schema:resendWebhookEvent:response */} ### Errors | Status | Error code(s) | Trigger | | --- | --- | --- | | 401 | `auth_invalid`, `signature_invalid`, `timestamp_out_of_window` | HMAC authentication failed | | 403 | `key_revoked`, `project_suspended` | API key revoked or project suspended | | 404 | `event_not_found` | `event_not_found` — either the event does not exist, belongs to a different project, or the path id is not a valid ULID | | 429 | `rate_limit_exceeded` | Per-project resend rate limit exceeded | | 500 | `internal_error` | Internal error | ### Examples ```bash curl -X POST https://txnod.com/api/v1/webhooks/events/01HK8MAR2QEXAMPLE000000000/resend \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: " ``` ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); const resent = await client.resendWebhookEvent('01HK8MAR2QEXAMPLE000000000'); console.log(resent.event_id, resent.original_event_id); ``` --- ## Sandbox simulation API — POST /api/v1/sandbox/* endpoint reference with cross-mode error codes. Source: # Sandbox simulation API Base URL: `https://txnod.com/api/v1/sandbox`. Every endpoint requires the same three HMAC headers as the rest of the API: - `X-Project-Id` — sandbox project ULID. - `X-Timestamp` — current Unix timestamp (seconds), ±300-second window. - `X-Signature` — HMAC-SHA256 hex digest of `${timestamp}.${rawBody}` using the sandbox API secret (`sk_sandbox_...`). The SDK (`client.sandbox.*` per [SDK reference](/sdk/sandbox)) handles signing transparently. Prefer it over hand-rolled cURL except for smoke tests. ## Authentication and cross-mode rules Sandbox endpoints accept **only** `sk_sandbox_...` API secrets. A production secret targeting any sandbox endpoint is rejected at the gateway. The cross-mode check fires only on the four explicit-`projectId` endpoints (`{projectId}/clock/advance`, `{projectId}/reset`, `DELETE {projectId}`, `{projectId}/wallets`). The 10 invoice-scoped simulate endpoints have an implicit project_id (derived from the API key), so the cross-mode check is tautological there and does not fire. ## Error taxonomy Every sandbox endpoint can return any of the following sandbox-specific error codes: | Status | `error_code` | Trigger | |---|---|---| | 400 | `sandbox_key_against_production_project` | Sandbox secret targeting production project. Fires only on the four explicit-`projectId` endpoints. | | 400 | `production_key_against_sandbox_project` | Production secret targeting sandbox project. Fires only on the four explicit-`projectId` endpoints. | | 403 | `sandbox_project_required` | Endpoint reached with non-sandbox project id (i.e. `projects.kind` is not `'sandbox'`). | | 404 | `sandbox_invoice_not_found` | Invoice id does not belong to the project. | | 422 | `sandbox_invoice_transition_invalid` | Current invoice status does not permit the requested simulation. | | 422 | `sandbox_invoice_terminal` | `simulate-duplicate-delivery` invoked but no terminal event exists yet. | | 429 | `sandbox_rate_limit_exceeded` | Per-project simulate-* rate cap (per chain) exceeded. | | 500 | `sandbox_reset_failed` | Internal error during `reset()` cascade. | | 500 | `sandbox_delete_failed` | Internal error during `DELETE` cascade. | Standard non-sandbox errors (`validation_error`, `auth_invalid`, `signature_invalid`, `timestamp_out_of_window`, `internal_error`) apply to every endpoint. ## Invoice-scoped routes Each route below acts on a single invoice belonging to the calling sandbox project. The project_id is implicit in the API key — cross-mode checks do not fire on these endpoints. ### `POST /sandbox/invoices/{invoiceId}/simulate-detect` Synthesizes an `invoice.detected` event (status `pending → detected`) and writes a synthetic `invoice_transactions` row whose `confirmations=0` is the starting point for subsequent `clock/advance` calls. Idempotent on `(invoice_id, 'detected')`. **Request body** — all fields optional: | Field | Type | Notes | |---|---|---| | `seed` | string | Deterministic seed for the synthesized `tx_hash`. Same seed → same hash. | **Response 200**: | Field | Type | |---|---| | `event_id` | string ULID | | `status` | `'detected'` | **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (invoice not in `pending`), `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-detect" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" \ -d '{"seed": "order-42"}' ``` ### `POST /sandbox/invoices/{invoiceId}/simulate-paid` Walks the invoice from `detected → paid`. Idempotent on `event_id` derived from `(invoice_id, 'paid')` so a re-confirm after a reorg replays with the same `event_id`. **Request body** — empty `{}`. **Response 200**: | Field | Type | |---|---| | `event_id` | string ULID | | `status` | `'paid'` | **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (invoice not in `detected`), `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-paid" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" -d '{}' ``` ### `POST /sandbox/invoices/{invoiceId}/simulate-overpaid` Walks the invoice from `detected → overpaid`. Pass either a `multiplier` (e.g. `1.5` for 150% of the invoice amount) or an explicit `extra_units` (chain-native smallest-unit string). **Request body** — exactly one of: | Field | Type | Notes | |---|---|---| | `multiplier` | number > 1 | Factor applied to the invoice's `amount_crypto_units`. | | `extra_units` | string | Additional smallest-units beyond the invoice amount. Pattern `^\d+$`. | **Response 200**: `{ event_id, status: 'overpaid' }`. The webhook delivered to your handler is `invoice.overpaid`. **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (invoice not in `detected`), `validation_error`, `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-overpaid" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" -d '{"multiplier": 1.5}' ``` ### `POST /sandbox/invoices/{invoiceId}/simulate-partial` Walks the invoice from `detected → partial`. Pass either a `fraction` (e.g. `0.5` for half the invoice amount) or an explicit `amount_units`. **Request body** — exactly one of: | Field | Type | Notes | |---|---|---| | `fraction` | number ∈ (0, 1) | Fraction of `amount_crypto_units` actually received. | | `amount_units` | string | Explicit smallest-units received. Pattern `^\d+$`. | **Response 200**: `{ event_id, status: 'detected' }` (invoice stays in `detected`; partial event fires). **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid`, `validation_error`, `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-partial" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" -d '{"fraction": 0.5}' ``` ### `POST /sandbox/invoices/{invoiceId}/simulate-expire` Forces the invoice to `expired`. **Request body** — empty `{}`. **Response 200**: `{ event_id, status: 'expired' }`. **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (invoice already terminal), `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-expire" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" -d '{}' ``` ### `POST /sandbox/invoices/{invoiceId}/simulate-late-payment` Followup to `simulate-expire`. Walks the invoice from `expired → expired_paid_late`. **Request body** — empty `{}`. **Response 200**: `{ event_id, status: 'expired_paid_late' }`. **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (invoice not in `expired`), `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-late-payment" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" -d '{}' ``` ### `POST /sandbox/invoices/{invoiceId}/simulate-reorg` Walks the invoice from `paid | overpaid | partial → reverted`. Emits `invoice.reverted`. **Request body** — empty `{}`. **Response 200**: `{ event_id, status: 'reverted' }`. **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (invoice not in a terminal-paid state), `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-reorg" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" -d '{}' ``` ### `POST /sandbox/invoices/{invoiceId}/simulate-reconfirm` After a reorg, fresh `paid` event with a stable, deterministic `event_id` (not the original) — exercises the dispatcher's idempotent re-emission contract. **Request body** — empty `{}`. **Response 200**: `{ event_id, status: 'paid' }`. **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (invoice not in `reverted`), `sandbox_rate_limit_exceeded`. ### `POST /sandbox/invoices/{invoiceId}/simulate-duplicate-delivery` Re-fires the most recent terminal webhook for this invoice with the SAME `event_id` — exercises the integrator's idempotency layer. **Request body** — empty `{}`. **Response 200**: `{ event_id }` (no status change). **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_terminal` (no terminal event yet), `sandbox_rate_limit_exceeded`. ### `POST /sandbox/invoices/{invoiceId}/simulate-event` Low-level escape hatch. Synthesizes an arbitrary event with caller-supplied `event_type`, `amount_units`, `confirmations`, `block_height`, optional `chain_specific` payload, the `expected_current_status` precondition (rejects the call if the invoice is not in this status), and an optional `seed`. **Request body**: | Field | Type | Required | Notes | |---|---|---|---| | `event_type` | enum of webhook event types | yes | One of `invoice.detected`, `invoice.paid`, `invoice.overpaid`, `invoice.partial`, `invoice.expired`, `invoice.expired_paid_late`, `invoice.reverted`. | | `amount_units` | string | yes | Pattern `^\d+$`. | | `confirmations` | integer | yes | min: 0. | | `block_height` | integer | yes | min: 0. | | `chain_specific` | object | no | Chain-namespaced extra fields. | | `expected_current_status` | invoice status enum | yes | Precondition; the simulate call rejects unless the invoice is currently in this status. | | `seed` | string | no | Deterministic seed for synthesized hashes. | **Response 200**: `{ event_id, status }` reflecting the new invoice status. **Errors**: `sandbox_invoice_not_found`, `sandbox_invoice_transition_invalid` (current status ≠ `expected_current_status`), `validation_error`, `sandbox_rate_limit_exceeded`. ## Project-scoped routes Each route below scopes by an explicit `{projectId}` in the path. Cross-mode checks fire here — a sandbox secret targeting a production project (or vice-versa) returns 400. ### `POST /sandbox/{projectId}/clock/advance` Increments the per-chain confirmation counter for every detected invoice in the project, walking detected invoices toward finalization. Per-chain rate-limited at 10 calls/min/project. **Request body**: | Field | Type | Required | Notes | |---|---|---|---| | `chain` | enum | yes | One of `btc`, `eth`, `tron`, `ada`, `polygon`, `bsc`, `ton`. | | `blocks` | integer | yes | min: 1; max: 64. | **Response 200**: | Field | Type | |---|---| | `advanced` | integer (count of invoices whose status changed) | | `remaining` | integer (count of detected invoices still below finalization) | **Errors**: `sandbox_project_required` (project is not sandbox-kind), `sandbox_key_against_production_project` (cross-mode), `production_key_against_sandbox_project` (cross-mode), `validation_error`, `sandbox_rate_limit_exceeded`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/$PROJECT_ID/clock/advance" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" \ -d '{"chain": "btc", "blocks": 6}' ``` ### `POST /sandbox/{projectId}/reset` Soft-purges the project's data tail (invoices, transactions, outbox events, address pool) while preserving the project shell, the seven xpubs, the chain bindings, the API key, and the sandbox PAT. Use as `beforeAll`/`afterAll` in test suites. **Request body** — empty `{}`. **Response 200**: `{ status: 'reset' }`. **Errors**: `sandbox_project_required`, `sandbox_key_against_production_project`, `production_key_against_sandbox_project`, `sandbox_reset_failed`. ```bash curl -X POST "https://txnod.com/api/v1/sandbox/$PROJECT_ID/reset" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" -d '{}' ``` ### `DELETE /sandbox/{projectId}` Cascade-deletes the entire sandbox project — shell, xpubs, bindings, API keys, sandbox PAT, all data tail. Irreversible. Per F-20. **Request body** — none. **Response 200**: `{ status: 'deleted' }`. **Errors**: `sandbox_project_required`, `sandbox_key_against_production_project`, `production_key_against_sandbox_project`, `sandbox_delete_failed`. ```bash curl -X DELETE "https://txnod.com/api/v1/sandbox/$PROJECT_ID" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" ``` ### `GET /sandbox/{projectId}/wallets` Read-only listing of per-chain sandbox xpubs (auto-provisioned at project creation). **Request body** — none. **Response 200**: `WalletsListResponse` — array of per-chain entries with `chain`, `xpub`, `bound_at_iso`. Schema mirrors the production wallets-list response. **Errors**: `sandbox_project_required`, `sandbox_key_against_production_project`, `production_key_against_sandbox_project`. ```bash curl -X GET "https://txnod.com/api/v1/sandbox/$PROJECT_ID/wallets" \ -H "X-Project-Id: $TXNOD_PROJECT_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" ``` ## Related - [SDK reference: `client.sandbox.*`](/sdk/sandbox) — the typed client surface. - [Sandbox projects](/guides/sandbox-projects) — project-creation flow + the agent loop. - [Sandbox safety](/guides/sandbox-safety) — seven-layer defense + the recommended CI assertion. - [Webhook event types](/webhooks/events) — every event carries `mode: 'production' | 'testnet' | 'sandbox'`. --- ## TxnodClient — Primary REST client with automatic HMAC signing. Source: # TxnodClient `TxnodClient` is the primary entry point of `@txnod/sdk`. Every method signs outbound requests with HMAC-SHA256 using the project's `apiSecret`, retries automatically on 429 and 5xx, and throws a typed subclass of `TxnodError` on any non-2xx response. > **AI coding agents:** a full, version-matched copy of this page and every other SDK guide ships inside the `@txnod/sdk` tarball at `node_modules/@txnod/sdk/docs/`, with an entry point at `node_modules/@txnod/sdk/AGENTS.md`. Prefer those offline files after install — they need no network access. ## Construction ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); ``` `baseUrl` defaults to `https://txnod.com`. Override only for staging or integration tests — production code should never hardcode a different origin. ## `createInvoice(body)` Create a new invoice. Idempotent on `(project_id, external_id)`. {/* @jsdoc:createInvoice */} ```ts import { TxnodClient, TxnodError, AddressVerificationError } from '@txnod/sdk'; // Set TXNOD_BTC_XPUB=zpub6r... in env to enable address verification. const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const invoice = await client.createInvoice({ amount_usd: 10.0, coin: 'btc', external_id: 'order-123', callback_url: 'https://my-site.com/webhooks/txnod', }); console.log(invoice.id, invoice.address, invoice.payment_uri); } catch (err) { if (err instanceof AddressVerificationError) { console.error('address verify failed', err.expected_address, err.derived_address); } else if (err instanceof TxnodError) { console.error(err.error_code, err.status, err.request_id); } throw err; } ``` {/* @end:jsdoc:createInvoice */} ## `getInvoice(id)` Fetch an invoice by ULID. {/* @jsdoc:getInvoice */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const invoice = await client.getInvoice('01HK8MAR2QEXAMPLE000000000'); console.log(invoice.status); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:getInvoice */} ## `searchInvoices(query)` Search invoices with snake_case query filters. Returns a cursor-paginated page. {/* @jsdoc:searchInvoices */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const page = await client.searchInvoices({ status: 'paid', limit: 20, }); for (const invoice of page.items) console.log(invoice.id); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:searchInvoices */} ## `cancelInvoice(id)` Cancel a pending or detected invoice and release its pool address into cooldown. {/* @jsdoc:cancelInvoice */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const cancelled = await client.cancelInvoice('01HK8MAR2QEXAMPLE000000000'); console.log(cancelled.status); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:cancelInvoice */} ## `listOrphanPayments(query)` List on-chain receipts that did not match any invoice address at detection time. {/* @jsdoc:listOrphanPayments */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const page = await client.listOrphanPayments({ chain: 'btc', limit: 50 }); for (const orphan of page.items) console.log(orphan.tx_hash); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:listOrphanPayments */} ## `attributeOrphanPayment(txHash, body)` Attribute an orphan payment to an external invoice id. Creates a synthetic `paid` invoice and emits an `invoice.paid` webhook event. {/* @jsdoc:attributeOrphanPayment */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const invoice = await client.attributeOrphanPayment('0xabc123', { external_id: 'order-123', }); console.log(invoice.id); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:attributeOrphanPayment */} ## `listWebhookEvents(query)` List outbound webhook events for the authenticated project. {/* @jsdoc:listWebhookEvents */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const page = await client.listWebhookEvents({ status: 'delivered', limit: 100, }); for (const event of page.items) console.log(event.id, event.status); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:listWebhookEvents */} ## `resendWebhookEvent(eventId)` Resend a previously-dispatched webhook event with a fresh `event_id`. {/* @jsdoc:resendWebhookEvent */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const resent = await client.resendWebhookEvent( '01HK8MAR2QEXAMPLE000000000', ); console.log(resent.event_id); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:resendWebhookEvent */} ## `getRates(query)` Indicative USD rates for enabled coins. The binding rate is captured by `createInvoice` in `rate_snapshot`. {/* @jsdoc:getRates */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const rates = await client.getRates({ coins: 'btc,eth' }); console.log(rates.rates.btc); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:getRates */} ## `quoteAmount(query)` Convert a USD amount into per-coin crypto amounts. Indicative only. {/* @jsdoc:quoteAmount */} ```ts import { TxnodClient, TxnodError } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); try { const quote = await client.quoteAmount({ amount_usd: 10.0, coins: 'btc', }); console.log(quote.quotes.btc); } catch (err) { if (err instanceof TxnodError) console.error(err.error_code); throw err; } ``` {/* @end:jsdoc:quoteAmount */} --- ## verifyWebhookSignature — Webhook HMAC + timestamp verification helper. Source: # verifyWebhookSignature `verifyWebhookSignature(headers, rawBody, secret)` parses the inbound `X-Txnod-Signature: t=,v1=` header, recomputes HMAC-SHA256 over `${timestamp}.${rawBody}` using the project secret, compares in constant time, and enforces a ±300-second timestamp window. On success it returns a typed `WebhookEvent` discriminated on `event_type`. On failure it throws one of three typed errors — never a generic `Error`. ## Signature ```ts import type { WebhookEvent } from '@txnod/sdk'; import { verifyWebhookSignature } from '@txnod/sdk'; declare const headers: Headers; declare const rawBody: string; declare const secret: string; const event: WebhookEvent = verifyWebhookSignature(headers, rawBody, secret); ``` - `headers` — anything that carries an `x-txnod-signature` header. `Headers`, `Record`, and `Record` all work. Case-insensitive. - `rawBody` — the exact bytes the server signed. Read it via `await request.text()` **before** any JSON parsing; `request.json()` consumes the stream and makes HMAC recomputation impossible. - `secret` — `TXNOD_WEBHOOK_SECRET` (same value as `TXNOD_API_SECRET`). When `event.mode === 'sandbox'`, see [Sandbox safety](../guides/sandbox-safety) for the recommended assertion. ## Worked example — Next.js App Router The recipe below uses the Next.js 16 App Router for concreteness; the SDK itself is pure Node ≥ 20 and the same `verifyWebhookSignature` call works unchanged in Express, Fastify, Hono, Koa, NestJS, Nuxt, SvelteKit, Remix, Astro endpoints, or plain `node:http` — only the body-reading idiom changes per framework. ```ts import { verifyWebhookSignature, TxnodSignatureFormatError, TxnodHmacError, TxnodTimestampError, } from '@txnod/sdk'; export async function POST(request: Request): Promise { const rawBody = await request.text(); try { const event = verifyWebhookSignature( request.headers, rawBody, process.env.TXNOD_WEBHOOK_SECRET!, ); switch (event.event_type) { case 'invoice.paid': { const invoiceId = event.data['invoice_id'] as string; console.log('paid', invoiceId, 'at', event.created_at_iso); break; } case 'invoice.reverted': { const invoiceId = event.data['invoice_id'] as string; console.log('reverted', invoiceId); break; } default: console.log('event type', event.event_type); } 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; } } ``` ## Error handling matrix | Error class | `error_code` | When thrown | Recommended response | | --- | --- | --- | --- | | `TxnodSignatureFormatError` | `auth_invalid` | `X-Txnod-Signature` missing or does not match the `t=,v1=` shape. | `401 Unauthorized`. Do not process the payload. | | `TxnodHmacError` | `signature_invalid` | Computed HMAC does not match the signature bytes. | `401 Unauthorized`. Do not retry or alert — a hostile sender will produce this every time. | | `TxnodTimestampError` | `timestamp_out_of_window` | Signed timestamp is more than ±300 seconds from server time. Carries `skew_seconds`. | `401 Unauthorized`. Alert if `skew_seconds` is chronically non-zero — the sender's clock is drifting. | All three extend `TxnodError`, so a single `catch (err)` block that checks `err instanceof TxnodError` is sufficient for coarse-grained handling. Branch on the specific subclass when the response or alert differs per failure mode — see the recipe above. ## Why no JSON schema validation The discriminated union on `event_type` is derived at SDK build time from the same Zod schemas the TxNod server uses, then expanded to plain TypeScript inside the published tarball. TypeScript narrows `event.data` correctly per `event_type` in editors and at compile time. The SDK does not run a second Zod parse at the client — the HMAC is the authentication boundary. `@txnod/shared` is **not** a published npm package; partners install only `@txnod/sdk` and never need `zod` at runtime. If you want belt-and-suspenders runtime validation, parse `event` against your own zod schema after `verifyWebhookSignature` returns. --- ## Errors — Typed error class hierarchy matching every API error code. Source: # Errors Every method on `TxnodClient` throws a subclass of `TxnodError` on a non-2xx response, and `verifyWebhookSignature` throws one of three dedicated subclasses on a bad signature. The base class carries the RFC 7807 problem-details envelope — `error_code`, `status`, `request_id`, and the raw body as `raw` — so partners can branch on the typed subclass or log `request_id` for support. ## Handling pattern ```ts 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!, }); try { await client.createInvoice({ external_id: 'order-1', amount_usd: 10, coin: 'btc', }); } catch (err) { if (err instanceof TxnodCoinNotEnabledError) { // actionable: point the user at a supported coin } else if ( err instanceof TxnodRateLimitError || err instanceof TxnodPoolExhaustedError ) { await new Promise((r) => setTimeout(r, err.retry_after_seconds * 1000)); // retry } else if (err instanceof TxnodError) { // generic TxNod error — log err.request_id console.error(err.error_code, err.request_id); } else { throw err; } } ``` ## All error classes {/* @errors:table */} | Class | `error_code` | HTTP | When thrown | How to handle | | --- | --- | --- | --- | --- | | `TxnodError` | — | — | Base class — thrown by every `TxnodClient` method on a non-2xx response. | Base class — narrow via `instanceof TxnodError` as a catch-all after checking specific subclasses. | | `TxnodSignatureFormatError` | `auth_invalid` | 401 | Webhook signature header is missing or malformed. | Malformed/missing `X-Txnod-Signature` header; reject the webhook with 401. | | `TxnodHmacError` | `signature_invalid` | 401 | Webhook signature does not match. | HMAC mismatch on inbound webhook; reject with 401 and do not process the payload. | | `TxnodTimestampError` | `timestamp_out_of_window` | 401 | Webhook timestamp is outside the ±300-second window. | `skew_seconds` reports clock drift; alert if chronically non-zero, then reject. | | `TxnodWebhookPayloadParseError` | `internal_error` | 401 | Webhook body passed HMAC but is not valid JSON. | — | | `TxnodValidationError` | `validation_error` | 400 | Thrown by `TxnodClient` when the server returns `error_code: validation_error`. | Inspect `err.raw.errors` for per-field issues and surface them to the caller. | | `TxnodInvalidCoinError` | `invalid_coin` | 400 | Thrown by `TxnodClient` when the server returns `error_code: invalid_coin`. | Map the user toward a supported coin from `getRates()`. | | `TxnodInvalidXpubFormatError` | `invalid_xpub_format` | 400 | Thrown by `TxnodClient` when the server returns `error_code: invalid_xpub_format`. | Surface the chain-specific xpub format requirement to the operator. | | `TxnodInvalidWebhookUrlError` | `invalid_webhook_url` | 400 | Thrown by `TxnodClient` when the server returns `error_code: invalid_webhook_url`. | The dashboard rejects non-https and non-public URLs; fix the webhook URL. | | `TxnodAuthInvalidError` | `auth_invalid` | 401 | Thrown by `TxnodClient` when the server returns `error_code: auth_invalid`. | Re-check `TXNOD_PROJECT_ID` and `TXNOD_API_SECRET`. | | `TxnodSignatureInvalidError` | `signature_invalid` | 401 | Thrown by `TxnodClient` when the server returns `error_code: signature_invalid`. | Outbound HMAC mismatch; inspect clock and secret, do not retry. | | `TxnodSignatureReplayedError` | `signature_replayed` | — | Thrown by `TxnodClient` when the server returns `error_code: signature_replayed`. | — | | `TxnodTimestampOutOfWindowError` | `timestamp_out_of_window` | 401 | Thrown by `TxnodClient` when the server returns `error_code: timestamp_out_of_window`. | Outbound timestamp outside ±300s window; check system clock. | | `TxnodKeySuspendedError` | `key_suspended` | 403 | Thrown by `TxnodClient` when the server returns `error_code: key_suspended`. | Operator suspended; do not auto-retry. | | `TxnodProjectSuspendedError` | `project_suspended` | 403 | Thrown by `TxnodClient` when the server returns `error_code: project_suspended`. | Project suspended; do not auto-retry. | | `TxnodPermissionDeniedError` | `permission_denied` | 403 | Thrown by `TxnodClient` when the server returns `error_code: permission_denied`. | API key lacks capability; surface to the operator. | | `TxnodKeyRevokedError` | `key_revoked` | 403 | Thrown by `TxnodClient` when the server returns `error_code: key_revoked`. | API key was rotated; fetch the new secret and redeploy. | | `TxnodInvoiceNotFoundError` | `invoice_not_found` | 404 | Thrown by `TxnodClient` when the server returns `error_code: invoice_not_found`. | Treat as 404 from the caller's perspective. | | `TxnodProjectNotFoundError` | `project_not_found` | 404 | Thrown by `TxnodClient` when the server returns `error_code: project_not_found`. | Project id mismatch; surface to the operator. | | `TxnodWalletNotFoundError` | `wallet_not_found` | 404 | Thrown by `TxnodClient` when the server returns `error_code: wallet_not_found`. | Chain wallet missing; register an xpub (see the [Wallets guide](/guides/wallets)). | | `TxnodExternalIdConflictError` | `external_id_conflict` | 409 | Thrown by `TxnodClient` when the server returns `error_code: external_id_conflict`. | Idempotent replay — fetch the existing invoice via `searchInvoices({ external_id })`. | | `TxnodXpubNotVerifiedError` | `xpub_not_verified` | 409 | Thrown by `TxnodClient` when the server returns `error_code: xpub_not_verified`. | Complete the index-0 handshake (see the [Wallets guide](/guides/wallets)). | | `TxnodCoinNotEnabledError` | `coin_not_enabled` | 422 | Thrown by `TxnodClient` when the server returns `error_code: coin_not_enabled`. | Enable the coin on the project or choose a different one. | | `TxnodAmountOutOfRangeError` | `amount_out_of_range` | 422 | Thrown by `TxnodClient` when the server returns `error_code: amount_out_of_range`. | Adjust `amount_usd` within project's accepted range. | | `TxnodRateLimitError` | `rate_limit_exceeded` | 429 | Thrown by `TxnodClient` when the server returns `error_code: rate_limit_exceeded`. | Back off for `retry_after_seconds`, then retry at most once. | | `TxnodPoolExhaustedError` | `pool_exhausted` | 503 | Thrown by `TxnodClient` when the server returns `error_code: pool_exhausted`. | Hard-cap hit; wait `retry_after_seconds` (server-provided) before retrying, or raise `poolSizePerChain` if this fires under steady load. | | `TxnodServerError` | `internal_error` | 500 | Thrown by `TxnodClient` when the server returns `error_code: internal_error`. | Transient; retry with exponential backoff. Log `request_id` for support. | | `TxnodInvoiceNotCancellableError` | `invoice_not_cancellable` | 409 | Thrown by `TxnodClient` when the server returns `error_code: invoice_not_cancellable`. | Invoice in a terminal state; no action — the charge is final. | | `TxnodInvalidStateTransitionError` | `invalid_state_transition` | 409 | Thrown by `TxnodClient` when the server returns `error_code: invalid_state_transition`. | Re-read the invoice before retrying the state change. | | `TxnodOrphanNotFoundError` | `orphan_not_found` | 404 | Thrown by `TxnodClient` when the server returns `error_code: orphan_not_found`. | The tx hash either does not exist or belongs to another project. | | `TxnodOrphanAlreadyAttributedError` | `orphan_already_attributed` | 409 | Thrown by `TxnodClient` when the server returns `error_code: orphan_already_attributed`. | Look up the existing invoice via `searchInvoices({ external_id })`. | | `TxnodEventNotFoundError` | `event_not_found` | 404 | Thrown by `TxnodClient` when the server returns `error_code: event_not_found`. | Event id unknown or from another project. | | `TxnodWalletNotBoundError` | `wallet_not_bound` | 422 | Thrown by `TxnodClient` when the server returns `error_code: wallet_not_bound`. | No verified wallet of matching kind is bound to the project for the requested chain. Bind one via the dashboard before retrying. | | `TxnodWalletKindMismatchError` | `wallet_kind_mismatch` | 422 | Thrown by `TxnodClient` when the server returns `error_code: wallet_kind_mismatch`. | Cross-kind binding rejected — production projects only accept production wallets; testnet projects only accept testnet wallets. | | `TxnodWalletNotOwnedError` | `wallet_not_owned` | — | Thrown by `TxnodClient` when the server returns `error_code: wallet_not_owned`. | — | | `TxnodWalletHasActiveBindingsError` | `wallet_has_active_bindings` | — | Thrown by `TxnodClient` when the server returns `error_code: wallet_has_active_bindings`. | — | | `TxnodSubscriptionExpiredError` | `subscription_expired` | — | Thrown by `TxnodClient` when the server returns `error_code: subscription_expired`. | — | | `TxnodTronNoActivatedAddressesError` | `tron_no_activated_addresses_available` | — | Thrown by `TxnodClient` when the server returns `error_code: tron_no_activated_addresses_available`. | — | | `TxnodTonOperatorWalletNotDeployedError` | `ton_operator_wallet_not_deployed` | — | Thrown by `TxnodClient` when the server returns `error_code: ton_operator_wallet_not_deployed`. | — | | `TxnodTonInvalidWalletVersionError` | `ton_invalid_wallet_version` | — | Thrown by `TxnodClient` when the server returns `error_code: ton_invalid_wallet_version`. | — | | `TxnodTonJettonResolveFailedError` | `ton_jetton_resolve_failed` | — | Thrown by `TxnodClient` when the server returns `error_code: ton_jetton_resolve_failed`. | — | | `TxnodTonCommentParseFailedError` | `ton_comment_parse_failed` | — | Thrown by `TxnodClient` when the server returns `error_code: ton_comment_parse_failed`. | — | | `TxnodTonConnectPayloadExpiredError` | `tonconnect_payload_expired` | — | Thrown by `TxnodClient` when the server returns `error_code: tonconnect_payload_expired`. | — | | `TxnodTonConnectPayloadUnknownError` | `tonconnect_payload_unknown` | — | Thrown by `TxnodClient` when the server returns `error_code: tonconnect_payload_unknown`. | — | | `TxnodTonConnectDomainMismatchError` | `tonconnect_domain_mismatch` | — | Thrown by `TxnodClient` when the server returns `error_code: tonconnect_domain_mismatch`. | — | | `TxnodTonConnectTimestampSkewError` | `tonconnect_timestamp_skew` | — | Thrown by `TxnodClient` when the server returns `error_code: tonconnect_timestamp_skew`. | — | | `TxnodTonConnectUnknownWalletVersionError` | `tonconnect_unknown_wallet_version` | — | Thrown by `TxnodClient` when the server returns `error_code: tonconnect_unknown_wallet_version`. | — | | `TxnodTonConnectSignatureInvalidError` | `tonconnect_signature_invalid` | — | Thrown by `TxnodClient` when the server returns `error_code: tonconnect_signature_invalid`. | — | | `TxnodTonConnectNetworkMismatchError` | `tonconnect_network_mismatch` | — | Thrown by `TxnodClient` when the server returns `error_code: tonconnect_network_mismatch`. | — | | `TxnodSandboxProjectRequiredError` | `sandbox_project_required` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_project_required`. | — | | `TxnodProductionProjectRequiredError` | `production_project_required` | — | Thrown by `TxnodClient` when the server returns `error_code: production_project_required`. | — | | `TxnodSandboxPerOperatorCapReachedError` | `sandbox_per_operator_cap_reached` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_per_operator_cap_reached`. | — | | `TxnodSandboxKeyAgainstProductionProjectError` | `sandbox_key_against_production_project` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_key_against_production_project`. | — | | `TxnodProductionKeyAgainstSandboxProjectError` | `production_key_against_sandbox_project` | — | Thrown by `TxnodClient` when the server returns `error_code: production_key_against_sandbox_project`. | — | | `TxnodSandboxProvisioningFailedError` | `sandbox_provisioning_failed` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_provisioning_failed`. | — | | `TxnodSandboxInvoiceTransitionInvalidError` | `sandbox_invoice_transition_invalid` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_invoice_transition_invalid`. | — | | `TxnodSandboxInvoiceNotFoundError` | `sandbox_invoice_not_found` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_invoice_not_found`. | — | | `TxnodSandboxInvoiceTerminalError` | `sandbox_invoice_terminal` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_invoice_terminal`. | — | | `TxnodSandboxRateLimitExceededError` | `sandbox_rate_limit_exceeded` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_rate_limit_exceeded`. | — | | `TxnodSandboxResetFailedError` | `sandbox_reset_failed` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_reset_failed`. | — | | `TxnodSandboxDeleteFailedError` | `sandbox_delete_failed` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_delete_failed`. | — | | `TxnodSandboxActiveInvoiceCapReachedError` | `sandbox_active_invoice_cap_reached` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_active_invoice_cap_reached`. | — | | `TxnodSandboxKeyInProductionError` | `sandbox_key_in_production` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_key_in_production`. | — | | `TxnodEnvironmentUnknownError` | `auth_invalid` | 401 | Sandbox API secret (sk_sandbox_*) detected but environment is unknown. Set the `environment` constructor option, TXNOD_ENVIRONMENT, or NODE_ENV. | — | | `TxnodSandboxXpubInProductionError` | `sandbox_xpub_in_production` | — | Thrown by `TxnodClient` when the server returns `error_code: sandbox_xpub_in_production`. | — | {/* @end:errors:table */} --- ## client.sandbox.* surface — SDK helper reference for the sandbox simulation surface plus environment-detection guards. Source: # client.sandbox.* surface The SDK exposes 14 sandbox methods on `client.sandbox.*`, mirroring the [Sandbox simulation API](/api/sandbox) one-to-one. Constructed lazily — bundlers tree-shake the entire namespace when never referenced. Each method signs requests with the same HMAC scheme as the rest of the SDK and throws typed `TxnodSandbox*` errors per spec §7.1. > **AI coding agents:** a full, version-matched copy of this page and every other SDK guide ships inside the `@txnod/sdk` tarball at `node_modules/@txnod/sdk/docs/05-sandbox.md`, with an entry point at `node_modules/@txnod/sdk/AGENTS.md`. Prefer those offline files after install — they need no network access. ## Construction ```ts import { TxnodClient } from '@txnod/sdk'; const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, // sk_sandbox_... environment: 'non-production', // explicit override; trusts NODE_ENV otherwise }); ``` The `environment` option (`'production' | 'non-production'`) takes precedence over `TXNOD_ENVIRONMENT` and `NODE_ENV`. Use `'non-production'` for tests and staging-replicas. ## `iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses` The escape-hatch constructor option. Default behaviour — when the SDK detects `NODE_ENV === 'production'` AND the configured secret starts with `sk_sandbox_` — is to throw `TxnodSandboxKeyInProductionError` at construction and refuse to instantiate. Setting `iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true` bypasses that hard-fail. **The only legitimate case for the override is a staging-replica** that mirrors production env-vars (e.g. `NODE_ENV=production`) but is wired to a sandbox project for safe testing. Even with the override: - The SDK emits a non-suppressible `console.error` on every constructor invocation describing the misconfiguration. - Every outbound API request carries an `X-Txnod-Client-Environment: production` header, surfacing the misuse in server-side admin telemetry. - The override option name is intentionally verbose so it remains awkward to type — renaming requires a major-version bump and a deprecation cycle. ```ts import { TxnodClient } from '@txnod/sdk'; // Staging-replica: NODE_ENV=production but secret is sandbox. const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true, }); ``` If you find yourself reaching for this in normal application code, the right fix is almost always `environment: 'non-production'` instead. ## `client.sandbox.simulateDetect(invoiceId, opts?)` Synthesizes `invoice.detected` (status `pending → detected`). ```ts import type { TxnodClient } from '@txnod/sdk'; declare const client: TxnodClient; const result = await client.sandbox.simulateDetect( '01HK8MAR2QEXAMPLE000000000', { seed: 'order-42' }, ); // result.event_id: ULID // result.status: 'detected' ``` **Throws**: `TxnodSandboxInvoiceNotFoundError`, `TxnodSandboxInvoiceTransitionInvalidError`, `TxnodSandboxRateLimitExceededError`. ## `client.sandbox.simulatePaid(invoiceId)` Walks the invoice from `detected → paid`. Returns `{ event_id, status: 'paid' }`. **Throws** the same error classes as `simulateDetect`. ## `client.sandbox.simulateOverpaid(invoiceId, params)` Walks from `detected → paid` (overpaid envelope). `params` is exactly one of `{ multiplier: number }` or `{ extra_units: string }`. ```ts import type { TxnodClient } from '@txnod/sdk'; declare const client: TxnodClient; declare const invoiceId: string; await client.sandbox.simulateOverpaid(invoiceId, { multiplier: 1.5 }); ``` **Throws**: `TxnodSandboxInvoiceNotFoundError`, `TxnodSandboxInvoiceTransitionInvalidError`, `TxnodValidationError`, `TxnodSandboxRateLimitExceededError`. ## `client.sandbox.simulatePartial(invoiceId, params)` Emits `invoice.partial`; invoice stays in `detected`. `params` is exactly one of `{ fraction: number }` or `{ amount_units: string }`. ```ts import type { TxnodClient } from '@txnod/sdk'; declare const client: TxnodClient; declare const invoiceId: string; await client.sandbox.simulatePartial(invoiceId, { fraction: 0.5 }); ``` **Throws**: same as `simulateOverpaid`. ## `client.sandbox.simulateExpire(invoiceId)` Forces `expired`. Returns `{ event_id, status: 'expired' }`. **Throws**: `TxnodSandboxInvoiceNotFoundError`, `TxnodSandboxInvoiceTransitionInvalidError`, `TxnodSandboxRateLimitExceededError`. ## `client.sandbox.simulateLatePayment(invoiceId, opts?)` Followup to `simulateExpire`. Walks `expired → expired_paid_late`. **Throws**: same as `simulateExpire`. ## `client.sandbox.simulateReorg(invoiceId)` Walks a terminal-paid invoice (`paid` / `overpaid` / `partial`) into `reverted`. Emits `invoice.reverted`. **Throws**: same as `simulateExpire`. ## `client.sandbox.simulateReconfirm(invoiceId)` After a reorg, fresh `paid` event with a stable, deterministic `event_id` (not a fresh ULID — exercises the dispatcher's idempotent re-emission contract). **Throws**: `TxnodSandboxInvoiceNotFoundError`, `TxnodSandboxInvoiceTransitionInvalidError` (invoice not in `reverted`), `TxnodSandboxRateLimitExceededError`. ## `client.sandbox.simulateDuplicateDelivery(invoiceId)` Re-fires the most recent terminal webhook for this invoice with the SAME `event_id` — exercises the integrator's idempotency layer. **Throws**: `TxnodSandboxInvoiceNotFoundError`, `TxnodSandboxInvoiceTerminalError` (no terminal event yet), `TxnodSandboxRateLimitExceededError`. ## `client.sandbox.simulateEvent(invoiceId, eventInput)` Low-level escape hatch. `eventInput` carries `{ event_type, amount_units, confirmations, block_height, chain_specific?, expected_current_status, seed? }`. Returns `{ event_id, status }`. **Throws**: `TxnodSandboxInvoiceNotFoundError`, `TxnodSandboxInvoiceTransitionInvalidError` (current status ≠ `expectedCurrentStatus`), `TxnodValidationError`, `TxnodSandboxRateLimitExceededError`. ## `client.sandbox.clockAdvance(projectId, params)` Per-project: increments the per-chain confirmation counter for every detected invoice. `params` is `{ chain, blocks }` (1 ≤ blocks ≤ 64). Per-chain rate-limited at 10 calls/min/project. ```ts import type { TxnodClient } from '@txnod/sdk'; declare const client: TxnodClient; declare const projectId: string; const r = await client.sandbox.clockAdvance(projectId, { chain: 'btc', blocks: 6, }); // r.advanced: number, r.remaining: number ``` **Throws**: `TxnodSandboxProjectRequiredError`, `TxnodSandboxKeyAgainstProductionProjectError`, `TxnodProductionKeyAgainstSandboxProjectError`, `TxnodValidationError`, `TxnodSandboxRateLimitExceededError`. ## `client.sandbox.reset(projectId)` Soft-purges the project's data tail (invoices, transactions, outbox events, address pool) while preserving the shell, xpubs, bindings, API key, and sandbox PAT. Use as `beforeAll`/`afterAll` in test suites. **Throws**: `TxnodSandboxProjectRequiredError`, `TxnodSandboxKeyAgainstProductionProjectError`, `TxnodProductionKeyAgainstSandboxProjectError`, `TxnodSandboxResetFailedError`. ## `client.sandbox.destroy(projectId)` Cascade-deletes the entire sandbox project — shell, xpubs, bindings, API keys, sandbox PAT, all data tail. Irreversible. **Throws**: `TxnodSandboxProjectRequiredError`, `TxnodSandboxKeyAgainstProductionProjectError`, `TxnodProductionKeyAgainstSandboxProjectError`, `TxnodSandboxDeleteFailedError`. ## `client.sandbox.listWallets(projectId)` Read-only listing of per-chain sandbox xpubs. Returns `WalletsListResponse`. Read-only — no transition errors fire. **Throws**: `TxnodSandboxProjectRequiredError`, `TxnodSandboxKeyAgainstProductionProjectError`, `TxnodProductionKeyAgainstSandboxProjectError`. ## Typed errors ### Safety-class errors These five typed errors implement the SDK's structural defense layers per the [Sandbox safety](../guides/sandbox-safety) layered-defense model. Each is exported from `@txnod/sdk`; reference the SDK source for the canonical class shape: - [`TxnodSandboxKeyInProductionError`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — sandbox API secret detected with a production-flagged environment (layer 2). - [`TxnodEnvironmentUnknownError`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — sandbox secret with no environment signal at all; safer to fail-closed (layer 2). - [`TxnodSandboxXpubInProductionError`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — testnet-prefixed xpub configured for a production-flagged environment (layer 3). - [`TxnodSandboxKeyAgainstProductionProjectError`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — sandbox API key targeting a production project (layer 4). - [`TxnodProductionKeyAgainstSandboxProjectError`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — production API key targeting a sandbox project (layer 4). ### Operational errors These three typed errors fire during normal sandbox operation and are caught case-by-case in handler logic: - `TxnodSandboxProjectRequiredError` — endpoint reached with a non-sandbox project id. - `TxnodSandboxInvoiceTransitionInvalidError` — current invoice status does not permit the requested simulate-* call. - `TxnodSandboxRateLimitExceededError` — per-project simulate-* rate cap exceeded. Additional sandbox-specific operational errors: `TxnodSandboxInvoiceNotFoundError`, `TxnodSandboxInvoiceTerminalError`, `TxnodSandboxResetFailedError`, `TxnodSandboxDeleteFailedError`. ## Related - [Sandbox simulation API](/api/sandbox) — the REST surface this client wraps. - [Sandbox safety](../guides/sandbox-safety) — the seven-layer defense and recommended CI assertion. - [verifyWebhookSignature](./verify-webhook-signature) — every event carries `mode: 'production' | 'testnet' | 'sandbox'`; assert in your handler. - [Sandbox projects](../guides/sandbox-projects) — project-creation flow. --- ## Webhook Event Types — Canonical list of webhook event types and envelopes. Source: # Webhook Event Types TxNod emits seven event types. Every event carries the same envelope — the `event_type` literal discriminates which invoice state transition occurred. The `data` payload follows the strict `WebhookEventData` shape (universal top-level fields + a chain-namespaced `chain_specific` object); new fields are added as additive optional properties so existing consumers stay binary-compatible across SDK versions. Every event is signed with HMAC-SHA256 over `${timestamp}.${rawBody}` and delivered with the single combined `X-Txnod-Signature: t=,v1=` header. See the [`verifyWebhookSignature` reference](/sdk/verify-webhook-signature) for the canonical handler. ## `invoice.detected` Emitted when the watcher observes a matching on-chain transaction at any confirmation count below the finalization threshold. Fires at most once per invoice per confirmation delta; duplicate observations from reorganizations are deduplicated by `event_id`. ```ts import type { WebhookEventData } from '@txnod/sdk'; type InvoiceDetectedEvent = { event_id: string; // ULID — stable across retries, per-event unique event_type: 'invoice.detected'; created_at: number; // Unix seconds created_at_iso: string; // ISO 8601 project_id: string; // ULID data: WebhookEventData; attempt: number; // 1-based delivery attempt // 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events mode: 'production' | 'testnet' | 'sandbox'; }; ``` Common `data` fields: `invoice_id`, `tx_hash`, `confirmations`, `amount_crypto`. ## `invoice.paid` Emitted when the invoice crosses the finalization threshold (coin-specific — e.g. 2 confirmations on BTC, 19 on TRON; see the [supported chains](/guides/supported-chains) matrix for the full per-chain table) and transitions `detected → paid`. Consumers should treat this as the authoritative "fulfill the order" signal. ```ts import type { WebhookEventData } from '@txnod/sdk'; type InvoicePaidEvent = { event_id: string; // ULID — stable across retries, per-event unique event_type: 'invoice.paid'; created_at: number; // Unix seconds created_at_iso: string; // ISO 8601 project_id: string; // ULID data: WebhookEventData; attempt: number; // 1-based delivery attempt // 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events mode: 'production' | 'testnet' | 'sandbox'; }; ``` Common `data` fields: `invoice_id`, `tx_hash`, `amount_crypto`, `amount_usd`, `confirmations`. ## `invoice.overpaid` Emitted when the total received amount exceeds the invoice amount by more than the project-configured tolerance. The invoice status is `overpaid` (terminal); the excess remains in the derived address and can be handled manually via the dashboard or the [Orphan Payments API](/api/orphan-payments). ```ts import type { WebhookEventData } from '@txnod/sdk'; type InvoiceOverpaidEvent = { event_id: string; // ULID — stable across retries, per-event unique event_type: 'invoice.overpaid'; created_at: number; // Unix seconds created_at_iso: string; // ISO 8601 project_id: string; // ULID data: WebhookEventData; attempt: number; // 1-based delivery attempt // 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events mode: 'production' | 'testnet' | 'sandbox'; }; ``` Common `data` fields: `invoice_id`, `amount_crypto_expected`, `amount_crypto_received`, `overpayment_crypto`. ## `invoice.partial` Emitted when the received amount is below the invoice amount after the expiry window plus grace period. Non-terminal — the invoice can still transition to `paid` or `overpaid` if a follow-up payment lands before the configured partial-timeout. ```ts import type { WebhookEventData } from '@txnod/sdk'; type InvoicePartialEvent = { event_id: string; // ULID — stable across retries, per-event unique event_type: 'invoice.partial'; created_at: number; // Unix seconds created_at_iso: string; // ISO 8601 project_id: string; // ULID data: WebhookEventData; attempt: number; // 1-based delivery attempt // 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events mode: 'production' | 'testnet' | 'sandbox'; }; ``` Common `data` fields: `invoice_id`, `amount_crypto_expected`, `amount_crypto_received`, `shortfall_crypto`. ## `invoice.expired` Emitted when the invoice expiry window elapses without any matching on-chain receipt. Terminal; the pool address is returned to cooldown. ```ts import type { WebhookEventData } from '@txnod/sdk'; type InvoiceExpiredEvent = { event_id: string; // ULID — stable across retries, per-event unique event_type: 'invoice.expired'; created_at: number; // Unix seconds created_at_iso: string; // ISO 8601 project_id: string; // ULID data: WebhookEventData; attempt: number; // 1-based delivery attempt // 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events mode: 'production' | 'testnet' | 'sandbox'; }; ``` Common `data` fields: `invoice_id`, `expired_at_iso`. ## `invoice.expired_paid_late` Emitted when a matching payment arrives **after** an invoice has already emitted `invoice.expired`. Idempotent on `(invoice_id, terminal_status)`: the original `invoice.expired` event is never retracted. Consumers should treat this as a delayed fulfillment signal and decide whether to honor the order. ```ts import type { WebhookEventData } from '@txnod/sdk'; type InvoiceExpiredPaidLateEvent = { event_id: string; // ULID — stable across retries, per-event unique event_type: 'invoice.expired_paid_late'; created_at: number; // Unix seconds created_at_iso: string; // ISO 8601 project_id: string; // ULID data: WebhookEventData; attempt: number; // 1-based delivery attempt // 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events mode: 'production' | 'testnet' | 'sandbox'; }; ``` Common `data` fields: `invoice_id`, `tx_hash`, `amount_crypto`, `received_at_iso`. ## `invoice.reverted` Emitted when a previously-`paid`/`overpaid` invoice loses its on-chain backing due to a reorganization. The invoice status flips to `reverted` (a terminal flag in its own right) and — if the same transaction re-lands on the canonical chain at sufficient depth — a new `invoice.paid` event fires with a stable, deterministic `event_id` derived from `(invoice_id, terminal_status)`. See [Reorg semantics](#reorg-semantics) below for how to dedupe. ```ts import type { WebhookEventData } from '@txnod/sdk'; type InvoiceRevertedEvent = { event_id: string; // ULID — stable across retries, per-event unique event_type: 'invoice.reverted'; created_at: number; // Unix seconds created_at_iso: string; // ISO 8601 project_id: string; // ULID data: WebhookEventData; attempt: number; // 1-based delivery attempt // 'production' for mainnet projects, 'testnet' for testnet projects, 'sandbox' for sandbox-project events mode: 'production' | 'testnet' | 'sandbox'; }; ``` Common `data` fields: `invoice_id`, `tx_hash`, `reverted_at_iso`, `reason`. ## Reorg semantics TxNod's finalization threshold is coin-specific — BTC waits 2 confirmations, TRON 19, and so on (see [supported chains](/guides/supported-chains) for the full table) — but reorganizations can still roll back a "paid" invoice on any chain. The `invoice.reverted` → re-emitted `invoice.paid` pair is the contract that lets consumers recover without losing idempotency. 1. Invoice enters `paid`; TxNod emits `invoice.paid` with `event_id = A`. 2. A reorg orphans the confirming block. The invoice flips to terminal status `reverted`; TxNod emits `invoice.reverted` with a fresh `event_id = B`. 3. The same transaction (or a functionally equivalent replacement) re-lands on the canonical chain at sufficient depth. The invoice enters `paid` again; TxNod emits `invoice.paid` with a **stable, deterministic** `event_id` derived from `(invoice_id, terminal_status)` — not a fresh ULID. Point 3 is the load-bearing idempotency invariant. If your consumer already processed `event_id = A` as "fulfill order", the re-emitted `invoice.paid` will arrive with the **same** `event_id`, so your `UNIQUE(event_id)` dedupe naturally skips it. Your system sees "this order was already fulfilled" and does nothing — which is exactly the right behavior, since you already reverted the fulfillment on `invoice.reverted`. **Do not rely on `tx_hash` for dedupe.** The re-emitted `invoice.paid` may carry a different `tx_hash` if the reorg replaced the transaction with a functionally equivalent one (e.g. a replacement of an RBF-enabled Bitcoin transaction). `event_id` is the only stable key. **Do not treat `invoice.paid` as irreversible inside its own handler.** The correct guarantee is: `event_id` is unique per terminal transition. If you reverse a fulfillment on `invoice.reverted`, the re-emitted `invoice.paid` will naturally replay through your normal handler — your `event_id` dedupe becomes a no-op only if you have already re-processed the same event_id. ## Delivery guarantees - At-least-once: events are retried up to 9 times with exponential backoff before landing in the DLQ (see [Configuring partner webhooks](/guides/webhooks) for resend procedures). - In-order-per-invoice is **not** guaranteed across different `event_type`s. Use the envelope's `created_at` (Unix seconds) or `created_at_iso` if your consumer needs a stable ordering. - `attempt` starts at 1 and increments on each retry. A resend initiated via `resendWebhookEvent` carries a fresh `event_id` and `attempt = 1`, with `original_event_id` populated on the resend-response API (not on the delivered event). --- ## Sandbox projects — Create a sandbox project, walk through the agent loop, graduate to production. Source: # Sandbox projects A **sandbox project** is a first-class TxNod project whose `kind` column is `'sandbox'` instead of `'production'`. Sandbox projects work end-to-end against the real REST API, the real webhook dispatcher, and the real SDK — without on-chain interaction. You drive every state transition with `client.sandbox.simulate*` and your webhook handler receives real signed events with `mode: 'sandbox'`. Sandbox projects are never billed, never count against subscription minimums, and are listed separately in the dashboard. ## What is a sandbox project? The `projects.kind` column discriminates a sandbox project from a production project. Every API key minted against a sandbox project carries the `sk_sandbox_` prefix; every webhook envelope dispatched from a sandbox project carries `mode: 'sandbox'`. There are no separate hosts, no separate database, no separate dashboard — sandbox and production share the same Postgres, the same authorization server, the same dispatcher. Discrimination is structural (the `kind` column, the secret prefix, the envelope mode), not infrastructural. Sandbox projects auto-provision testnet xpubs across all seven supported chains at creation time — there is no wallet wizard, no Ledger handshake, no faucet. Pool addresses derive from the auto-provisioned xpubs at indices 1, 2, 3, onward exactly as production projects do; the only difference is that no real funds ever land on those addresses because every "payment" is synthesized by `client.sandbox.simulate*`. ## Create a sandbox project In the dashboard, navigate to **Projects → + New sandbox project** at [https://txnod.com/projects/new-sandbox](https://txnod.com/projects/new-sandbox). One click provisions the project shell, the seven testnet xpubs, the per-chain bindings, and a sandbox PAT scoped to `sandbox:simulate`. The success screen surfaces four secrets — copy each into your local `.env` file or secrets manager: - `TXNOD_PROJECT_ID` — ULID identifying the project (same shape as production project IDs). - `TXNOD_API_SECRET` — `sk_sandbox_...` HMAC secret. The `sk_sandbox_` prefix is the SDK's first-line guard against accidental mainnet routing. - `TXNOD_WEBHOOK_SECRET` — independent per-project secret used to sign outbound webhooks. **Different from the API secret** (this is the F-16 invariant — production and sandbox each have their own webhook secret; samples wire `TXNOD_WEBHOOK_SECRET_SANDBOX` as a second env var). - Sandbox PAT — Personal Access Token carrying the `sandbox:simulate` scope. Used by MCP tools and by automated test harnesses that need to drive the simulate-* loop. Cross-tenant guarantee: the 14 sandbox MCP tools each call a 4-invariant guard (`row exists` / `not soft-deleted` / `kind === 'sandbox'` / `ownerUserId` match) that collapses any miss into a single not-found surface — see [Layer 7](./sandbox-safety#layer-7--mcp-tool-cross-tenant-guard). Each secret is shown exactly once. Re-issue from the dashboard if any value is lost. ## What you get - **Auto-provisioned testnet xpubs** — one per chain (BTC, ETH, TRON, Cardano, Polygon PoS, BSC, TON), all bound to the project at creation. Pool address allocator works exactly as in production: index 0 is reserved for the handshake (auto-confirmed for sandbox), invoices use indices 1+, cooldown semantics identical. - **Sandbox-scoped API secret** — `sk_sandbox_...` prefixed. The SDK refuses to instantiate `TxnodClient` with a sandbox secret in a production-detected environment unless you set `iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true` (and even then it logs a non-suppressible `console.error` and flags every request with `X-Txnod-Client-Environment: production`). - **Independent webhook secret** — `TXNOD_WEBHOOK_SECRET` for the project. Distinct from the API secret and from any production project's webhook secret. Sample integrations declare both `TXNOD_WEBHOOK_SECRET` and `TXNOD_WEBHOOK_SECRET_SANDBOX` so a single deploy can verify inbound webhooks from either mode. - **Sandbox PAT** — `sandbox:simulate` scope. Drives the 14 sandbox MCP tools and the automated test harnesses. Cannot drive simulation against production projects (cross-mode rejection per spec §5.6). ## The agent loop Sandbox projects are designed for an integrate → exercise → verify loop driven by an AI coding agent. The agent reads `node_modules/@txnod/sdk/AGENTS.md`, installs `@txnod/sdk`, writes the integration code, then walks all 7 webhook events deterministically using `client.sandbox.simulate*`. See the [Agent-driven testing](./agent-driven-testing) guide for the complete loop, including a worked Next.js 16 App Router example with `/api/checkout` + `/api/txnod-webhook` route handlers. ## Drive the state machine The sandbox surface exposes 14 methods (10 invoice-scoped, 4 project-scoped) — same names as the matching MCP tools (`sandbox_simulate_detect`, etc.). Each call advances exactly one state transition and emits exactly one webhook event. The 7 transition scenarios cover the full event matrix: | Scenario | Calls | Events emitted | |---|---|---| | `detected → paid` | `simulateDetect` then `simulatePaid` | `invoice.detected`, `invoice.paid` | | `detected → overpaid` | `simulateDetect` then `simulateOverpaid` | `invoice.detected`, `invoice.overpaid` | | `detected → partial` | `simulateDetect` then `simulatePartial` | `invoice.detected`, `invoice.partial` | | `pending → expired` | `simulateExpire` | `invoice.expired` | | `expired → expired_paid_late` | `simulateExpire` then `simulateLatePayment` | `invoice.expired`, `invoice.expired_paid_late` | | `paid → reverted → paid` | `simulateDetect`, `simulatePaid`, `simulateReorg`, `simulateReconfirm` | `invoice.detected`, `invoice.paid`, `invoice.reverted`, `invoice.paid` (re-emitted with stable `event_id`) | | Duplicate delivery | `simulateDuplicateDelivery` | re-fires the most recent terminal event with the SAME `event_id` | `clockAdvance(projectId, { chain, blocks })` increments per-chain confirmation counters across detected invoices — useful for integrations that gate fulfillment on `event.data.confirmations`. ## Reset between runs `client.sandbox.reset(projectId)` soft-purges the data tail — invoices, transactions, outbox events, the address pool — while preserving the project shell, the seven xpubs, the chain bindings, the API key, and the sandbox PAT. Idiomatic Vitest usage: ```ts import { TxnodClient } from '@txnod/sdk'; declare const beforeAll: (fn: () => Promise) => void; declare const afterAll: (fn: () => Promise) => void; declare const client: TxnodClient; beforeAll(async () => { await client.sandbox.reset(process.env.TXNOD_PROJECT_ID!); }); afterAll(async () => { await client.sandbox.reset(process.env.TXNOD_PROJECT_ID!); }); ``` The dashboard surfaces a one-click **Reset** button on the sandbox project detail page that calls the same endpoint. ## Auto-cleanup policy Sandbox projects participate in an automatic cleanup policy (per F-21): - **30 days idle → soft-purge.** A sandbox project with no API or simulate-* activity for 30 consecutive days has its data tail purged on the same schedule as a manual `reset()`. The shell, the xpubs, and the bindings stay; the secrets remain valid. - **90 days idle → full delete.** A sandbox project with no activity for 90 days is fully cascade-deleted, including the shell. Secrets stop working at that point. The dashboard surfaces a banner on the sandbox detail page when the project is approaching either threshold so an integrator can re-activate (`reset()` or any simulate-* call resets the idle clock). ## Safety > Sandbox secrets and sandbox projects exist in the same Postgres database as production. Discrimination is structural — the `sk_sandbox_` prefix, the `projects.kind` column, the webhook envelope's `mode` discriminator, and the SDK's environment-detection guards. Six independent layers of defense sit between the integrator and accidentally routing real customer funds to a sandbox address. ### Per-chain format-safety truth table For 4 of the 15 supported assets, the chain *itself* rejects a sandbox→mainnet send because the address format differs between mainnet and testnet. For the remaining 11 assets — every EVM-family chain plus the TRON family — the address format is identical between mainnet and testnet, so format protection is impossible and safety relies on the structural layers below. | Chain | Mainnet format | Sandbox/testnet format | Mainnet wallet rejects send? | Mechanism | |---|---|---|---|---| | Bitcoin | `bc1q...` (bech32 mainnet HRP) / `1...` / `3...` | `tb1q...` (bech32 testnet HRP) / `m...` / `n...` / `2...` | Yes | Bech32 HRP byte differs; legacy address version byte differs | | Ethereum (ETH + ERC-20 USDT/USDC) | `0x...` 20-byte hex | `0x...` 20-byte hex | **No** | Identical address format; safety via chain id at signing time | | Polygon PoS (POL + Polygon USDT/USDC) | `0x...` 20-byte hex | `0x...` 20-byte hex | **No** | Identical address format; safety via chain id | | BNB Smart Chain (BNB + BEP-20 USDT/USDC) | `0x...` 20-byte hex | `0x...` 20-byte hex | **No** | Identical address format; safety via chain id | | TRON (TRX + TRC-20 USDT) | `T...` (base58check, 0x41 leading byte) | `T...` (base58check, 0x41 leading byte — testnet uses same byte) | **No** | Identical address format; testnet differentiation is endpoint-only | | Cardano | `addr1...` (mainnet NetworkId) | `addr_test1...` (testnet NetworkId) | Yes | Address-level NetworkId byte differs | | TON (Toncoin) | `EQ...` / `UQ...` (mainnet workchain prefix) | `kQ...` / `0Q...` (testnet workchain prefix) | Yes | Address prefix byte encodes workchain + bounceable bit | | TON (jetton USDT-TON) | `EQ...` / `UQ...` mainnet | `kQ...` / `0Q...` testnet | Yes | Same workchain-prefix mechanism | **Coverage line: 4 of 15 assets format-safe (BTC, ADA, TON, USDT-TON); 11 assets — every EVM-family + TRON family — cannot be format-protected.** ### Six-layer defense The structural layers below are what catch the 11 non-format-safe assets and what double-cover the 4 format-safe ones: 1. **API-secret prefix.** Every sandbox secret begins with `sk_sandbox_`. Production secrets use a different (production) prefix. Anyone — human or agent — can grep their `.env` files for the prefix to verify they match the deployed `NODE_ENV`. 2. **SDK constructor hard-fail.** When the SDK detects `NODE_ENV === 'production'` (or the explicit `environment: 'production'` constructor option) AND the configured secret starts with `sk_sandbox_`, instantiation throws `TxnodSandboxKeyInProductionError`. Override exists (`iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true`) but is deliberately verbose, logs a non-suppressible `console.error`, and flags every request with `X-Txnod-Client-Environment: production`. 3. **SDK xpub-prefix guard.** When `TXNOD__XPUB` configured for a production project starts with a testnet prefix (`tpub`, `vpub`, `upub`), the SDK throws `TxnodSandboxXpubInProductionError` at boot. (Carve-out: EVM-family `xpub` prefixes are valid on both mainnet and testnet; this layer only catches BTC-family testnet prefixes.) 4. **Server cross-mode rejection.** A sandbox API secret targeting a production project (or a production secret targeting a sandbox project) is rejected at the gateway with `error_code: sandbox_key_against_production_project` (HTTP 400) or `production_key_against_sandbox_project`. The SDK throws `TxnodSandboxKeyAgainstProductionProjectError` / `TxnodProductionKeyAgainstSandboxProjectError`. 5. **Webhook envelope `mode`.** Every outbound webhook carries `mode: 'production' | 'testnet' | 'sandbox'`. Integrators assert in their handler: `if (event.mode === 'sandbox' && process.env.NODE_ENV === 'production') throw new Error('refusing to process sandbox event in production');`. 6. **Documentation and CI assertion.** The recommended CI assertion (see below) is reproduced byte-exact in the SDK-bundled `05-sandbox.md`, this guide, the [Sandbox safety](./sandbox-safety) standalone reference, and every example app's `tests/setup.ts`. An agent or human integrator can grep for the canonical text to verify they're aligned. ### Recommended CI assertion Add this once at the top of your test setup so a misconfiguration breaks CI loudly: ```ts // tests/setup.ts function assertSafeMode(): void { const env = process.env.NODE_ENV ?? 'unknown'; const secret = process.env.TXNOD_API_SECRET ?? ''; const isSandbox = secret.startsWith('sk_sandbox_'); if (env === 'production' && isSandbox) { throw new Error( '[ci] Sandbox API secret cannot run with NODE_ENV=production', ); } if (env !== 'production' && !isSandbox) { throw new Error( '[ci] Production API secret cannot run outside production', ); } } assertSafeMode(); export {}; ``` ### What this means in practice For the four chains where the chain itself rejects sandbox→mainnet sends (BTC, ADA, TON, USDT-TON), a mainnet wallet that tries to pay a sandbox address fails at submit time — the wallet displays an "invalid address for this network" error and no funds move. For the other 11 assets — every ETH/Polygon/BSC ERC-20-style asset and every TRON TRC-20 asset — the safety must come from the SDK's environment-detection layer, the server's cross-mode rejection, and the webhook envelope's `mode` discriminator. **The recommended CI assertion (block above) is the single most load-bearing line of defense for those 11 assets** — it ensures a sandbox secret cannot run with `NODE_ENV=production` even if every other layer is bypassed. For the standalone safety reference with code examples per defense layer and three concrete failure-scenario walkthroughs, read [Sandbox safety](./sandbox-safety). ## Graduate to production When you're ready to ship to mainnet: 1. **Register a hardware-wallet xpub** via the production project's Wallets wizard (Ledger via WebHID, or paste from Ledger Live / Sparrow / Electrum / Cardano CIP-30 browser wallet / TON Connect). Sandbox auto-provisioning does not exist for production projects. 2. **Create a production project** with a non-`sk_sandbox_` API secret. The dashboard's `+ New project` button (not the sandbox button) provisions the project shell. 3. **Swap `sk_sandbox_...` for the production secret** in your deployed `.env`. Keep the sandbox secret in your local dev `.env.local` so you can keep running the test suite during development. 4. **Drop your `event.mode === 'sandbox'` assertions** that gated test-only behavior — production webhooks carry `mode: 'production'`, so the assertion now fails-closed if a sandbox event ever leaks into production. 5. **Re-read [Sandbox safety](./sandbox-safety)** — confirm every defense layer is wired in your production deploy, not just the sandbox one. The graduation path is intentionally explicit: you don't "flip a switch" — you create a new project, register a real xpub, and swap the secret. The sandbox project keeps existing in parallel for ongoing CI runs. --- ## Agent-driven testing — How an AI coding agent uses sandbox simulate-* to walk all 7 webhook events deterministically. Source: # Agent-driven testing Sandbox projects are designed for an AI coding agent to integrate `@txnod/sdk` end-to-end, exercise every webhook event deterministically, and verify the integration before any production wallet exists. This guide is the agent-loop deep dive — what the agent reads, how it writes the integration, how it drives the simulate-* matrix, how it verifies, and where it stops. For the project-creation flow and safety analysis, read [Sandbox projects](./sandbox-projects) and [Sandbox safety](./sandbox-safety) first. ## The agent loop The loop is **integrate → exercise → verify**. The agent is given four credentials (`TXNOD_PROJECT_ID`, `TXNOD_API_SECRET` starting `sk_sandbox_`, `TXNOD_WEBHOOK_SECRET`, sandbox PAT) and a prompt; it produces a working webhook handler plus a Vitest suite that proves all 7 events round-trip with `mode: 'sandbox'` set, then stops at sandbox-green. No production credentials, no hardware wallet, no faucet are involved. ## What the agent reads first The bundled documentation is the authoritative entry point — frozen at the installed SDK version, network-free: - [`node_modules/@txnod/sdk/AGENTS.md`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — read order, non-negotiable invariants. - [`node_modules/@txnod/sdk/docs/05-sandbox.md`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — `client.sandbox.*` surface, environment-detection guards, layered defenses, per-chain testnet truth table. - [`node_modules/@txnod/sdk/docs/examples/sandbox-vitest-suite.md`](https://www.npmjs.com/package/@txnod/sdk?activeTab=code) — the canonical 7-scenario Vitest harness an agent should mirror. - [`https://docs.txnod.com/llms.txt`](https://docs.txnod.com/llms.txt) and [`llms-full.txt`](https://docs.txnod.com/llms-full.txt) — the indexed corpus when an offline copy is not available. ## How to write the integration A complete worked example fits in three pieces: a shared `TxnodClient` constructed at module scope, a two-secret routing helper for dual-mode receivers, and two route handlers (`checkout` to mint invoices, `webhook` to verify and dispatch events). The shape uses Web-standard `Request`/`Response` so it ports verbatim into Next.js App Router, Hono, Fastify (with `request.raw`), and any Web-Fetch runtime; for Express, swap `request.text()` for `req.rawBody.toString('utf8')` after `express.raw()`. ```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', }); ``` ```ts // Two-secret routing for dual-mode receivers — sandbox callbacks reach the // same URL as production. Peek the body for the "mode":"sandbox" literal // before HMAC verification; if present, use the sandbox secret. export function pickWebhookSecret(rawBody: string): string { const looksSandbox = /"mode"\s*:\s*"sandbox"/.test(rawBody); return looksSandbox ? process.env['TXNOD_WEBHOOK_SECRET_SANDBOX']! : process.env['TXNOD_WEBHOOK_SECRET']!; } ``` ```ts import { TxnodClient } from '@txnod/sdk'; declare const txnod: TxnodClient; export async function checkout(request: Request): Promise { 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, }); } ``` ```ts import { verifyWebhookSignature, TxnodHmacError, TxnodTimestampError, } from '@txnod/sdk'; declare function pickWebhookSecret(rawBody: string): string; const seenEventIds = new Set(); // replace with your dedupe store export async function webhook(request: Request): Promise { const rawBody = await request.text(); try { const event = verifyWebhookSignature( request.headers, rawBody, pickWebhookSecret(rawBody), ); if (event.mode === 'sandbox' && process.env['NODE_ENV'] === 'production') { return Response.json( { error: 'refusing sandbox event in production' }, { 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 in this branch — fulfil order } return Response.json({ ok: true }); } catch (err) { if (err instanceof TxnodHmacError || err instanceof TxnodTimestampError) { return Response.json({ error: 'signature' }, { status: 401 }); } throw err; } } ``` The pattern that makes this agent-friendly: dedupe on `event.event_id` (stable across retries and reorg-replays), branch on `event.event_type` for narrowed `event.data` types, fail-close when `event.mode === 'sandbox'` is observed in `NODE_ENV=production`. The two-secret routing means a single deploy can verify both production and sandbox callbacks without per-route forking. The agent prompt structure that produces clean integrations is: ``` You are integrating @txnod/sdk into this project. Default to sandbox mode. Credentials (already in .env.local): TXNOD_PROJECT_ID TXNOD_API_SECRET (starts with sk_sandbox_) TXNOD_WEBHOOK_SECRET TXNOD_WEBHOOK_SECRET_SANDBOX TXNOD_PAT (sandbox:simulate scope) Steps: 1. Read node_modules/@txnod/sdk/AGENTS.md and docs/05-sandbox.md. 2. Mirror the route shape from docs/examples/nextjs-route-handler.md. 3. Implement two-secret webhook routing (sandbox first, fall through to production). 4. Idempotent-dedup on event.event_id. 5. Branch handler logic on event.mode === 'sandbox' to fail-closed in production. 6. Write a Vitest suite that drives all 7 simulate-* scenarios. 7. Run pnpm test until green; STOP. Do not deploy, do not push. ``` A typical agent trace follows the seven-step prompt above: read the bundled `AGENTS.md` + `docs/05-sandbox.md`, install `@txnod/sdk`, scaffold the two route handlers shown above, wire the two-secret helper, implement an `event.event_id` dedupe set, write a 7-scenario Vitest suite that drives the simulate-* matrix below, run `pnpm test` until green, then stop. The agent never proceeds to mainnet without explicit human approval — see [Stop condition](#stop-condition). ## How to drive the simulate-* loop Sandbox state transitions are deterministic — every method call advances exactly one state and emits exactly one webhook (except `simulateDuplicateDelivery`, which re-fires the most recent terminal event with the same `event_id`). The 7 scenarios that cover the full event matrix: | Scenario | SDK calls | MCP tools | Webhook events delivered | |---|---|---|---| | 1. detected → paid | `simulateDetect`, `simulatePaid` | `sandbox_simulate_detect`, `sandbox_simulate_paid` | `invoice.detected`, `invoice.paid` | | 2. detected → overpaid | `simulateDetect`, `simulateOverpaid` | `sandbox_simulate_detect`, `sandbox_simulate_overpaid` | `invoice.detected`, `invoice.overpaid` | | 3. detected → partial | `simulateDetect`, `simulatePartial` | `sandbox_simulate_detect`, `sandbox_simulate_partial` | `invoice.detected`, `invoice.partial` | | 4. pending → expired | `simulateExpire` | `sandbox_simulate_expire` | `invoice.expired` | | 5. expired → expired_paid_late | `simulateExpire`, `simulateLatePayment` | `sandbox_simulate_expire`, `sandbox_simulate_late_payment` | `invoice.expired`, `invoice.expired_paid_late` | | 6. paid → reverted → paid (reorg + reconfirm) | `simulateDetect`, `simulatePaid`, `simulateReorg`, `simulateReconfirm` | `sandbox_simulate_detect`, `sandbox_simulate_paid`, `sandbox_simulate_reorg`, `sandbox_simulate_reconfirm` | `invoice.detected`, `invoice.paid`, `invoice.reverted`, `invoice.paid` (re-emitted with stable `event_id`) | | 7. duplicate delivery (idempotency) | `simulateDuplicateDelivery` | `sandbox_simulate_duplicate_delivery` | re-fires most recent terminal event with the SAME `event_id` | `clockAdvance(projectId, { chain, blocks })` increments per-chain confirmation counters across detected invoices — drive it when integration logic gates on `event.data.confirmations`. `reset(projectId)` is called as `beforeAll` and `afterAll` so each test run starts clean. `destroy(projectId)` cascades the entire project (use only at end-of-life — sandbox projects are usually kept long-lived). ## How to verify The agent verifies correctness in three places: 1. **`assertSafeMode()` at test boot.** The byte-exact CI assertion (see [Sandbox safety](./sandbox-safety) → Recommended CI assertion) imported from `tests/setup.ts` fails the run if the env wiring is wrong. 2. **Expected `event_type` and terminal `status` per scenario.** Each scenario polls `listWebhookEvents` for the expected event-type set (`invoice.detected`, `invoice.paid`, etc.) and asserts the SDK's returned `status`. The structural sandbox-mode invariant is enforced server-side by the dispatcher (every webhook from a `kind='sandbox'` project carries `mode: 'sandbox'`); receiver-side per-event `mode` checks live in the route handler at `src/app/api/txnod-webhook/route.ts`, which fail-closes when `event.mode === 'sandbox'` is observed in `NODE_ENV=production`. 3. **The 7-scenario Vitest suite, all green.** This is the operational definition of "agent-ready" — sandbox-green proves the integration handles every webhook type the dispatcher can emit. The handler MUST dedup on `event.event_id` (not on `tx_hash`, not on `(invoice_id, event_type)`) — `simulateDuplicateDelivery` and the `paid → reverted → paid` reconfirm flow both re-fire the same `event_id` and your idempotency layer is the device under test. ## Stop condition **The agent stops at sandbox-green.** Sandbox-green is the operational completion signal — all 7 scenarios pass, `assertSafeMode()` is wired, the handler dedups on `event_id`, the integration commits to git. The agent does NOT proceed to mainnet, does NOT swap to a production secret, does NOT register a real wallet — those steps belong to the human owner of the project. Cross-link to the [Sandbox projects → Graduate to production](./sandbox-projects#graduate-to-production) section for the human's promotion checklist. ## What NOT to do These anti-patterns must never appear in agent-authored code: - **Do NOT promote a sandbox xpub to a production env.** Sandbox xpubs are testnet-derived; using them in production routes real customer funds to addresses an attacker could reach via the public testnet faucet. - **Do NOT bypass `iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses` without explicit human approval.** The override exists for staging-replica setups that mirror production env vars; defaulting to it in agent-generated code is a category error. - **Do NOT paste a `sk_sandbox_...` API secret into a production `.env`.** Always keep the sandbox secret in `.env.local` (developer machines + CI) and the production secret in the deployed environment. - **Do NOT ignore the `mode` field in the webhook handler.** A handler that doesn't branch on `event.mode` will happily process a sandbox event in production, defeating layer 5 of the seven-layer defense. - **Do NOT proceed to mainnet without the human's explicit approval.** Sandbox-green is the stop condition; mainnet promotion is a separate human-driven step (see [Sandbox projects → Graduate to production](./sandbox-projects#graduate-to-production)). For the full safety analysis with per-layer code examples and three failure-scenario walkthroughs, read [Sandbox safety](./sandbox-safety). For the project-creation flow, read [Sandbox projects](./sandbox-projects). --- ## Sandbox safety — Per-chain format-safety truth table, seven-layer defense, and the recommended CI assertion. Source: # Sandbox safety This is the standalone safety reference for TxNod's sandbox surface. It covers per-chain format safety, the seven-layer defense, three concrete failure scenarios with the exact error message text an integrator sees, and the recommended CI assertion. For the project-creation flow, read [Sandbox projects](./sandbox-projects). For the agent-driven testing loop, read [Agent-driven testing](./agent-driven-testing). ## Per-chain truth table For 4 of the 15 supported assets, the chain *itself* rejects a sandbox→mainnet send because the address format differs between mainnet and testnet. For the remaining 11 assets — every EVM-family chain plus the TRON family — the address format is identical between mainnet and testnet, so format protection is impossible and safety relies on the structural layers below. | Chain | Mainnet format | Sandbox/testnet format | Mainnet wallet rejects send? | Mechanism | |---|---|---|---|---| | Bitcoin | `bc1q...` (bech32 mainnet HRP) / `1...` / `3...` | `tb1q...` (bech32 testnet HRP) / `m...` / `n...` / `2...` | Yes | Bech32 HRP byte differs; legacy address version byte differs | | Ethereum (ETH + ERC-20 USDT/USDC) | `0x...` 20-byte hex | `0x...` 20-byte hex | **No** | Identical address format; safety via chain id at signing time | | Polygon PoS (POL + Polygon USDT/USDC) | `0x...` 20-byte hex | `0x...` 20-byte hex | **No** | Identical address format; safety via chain id | | BNB Smart Chain (BNB + BEP-20 USDT/USDC) | `0x...` 20-byte hex | `0x...` 20-byte hex | **No** | Identical address format; safety via chain id | | TRON (TRX + TRC-20 USDT) | `T...` (base58check, 0x41 leading byte) | `T...` (base58check, 0x41 leading byte — testnet uses same byte) | **No** | Identical address format; testnet differentiation is endpoint-only | | Cardano | `addr1...` (mainnet NetworkId) | `addr_test1...` (testnet NetworkId) | Yes | Address-level NetworkId byte differs | | TON (Toncoin) | `EQ...` / `UQ...` (mainnet workchain prefix) | `kQ...` / `0Q...` (testnet workchain prefix) | Yes | Address prefix byte encodes workchain + bounceable bit | | TON (jetton USDT-TON) | `EQ...` / `UQ...` mainnet | `kQ...` / `0Q...` testnet | Yes | Same workchain-prefix mechanism | **Coverage line: 4 of 15 assets format-safe (BTC, ADA, TON, USDT-TON); 11 assets — every EVM-family + TRON family — cannot be format-protected.** ## Layered defenses The seven layers below catch the 11 non-format-safe assets and double-cover the 4 format-safe ones. Each layer is independent — bypassing one does not bypass the others. ### Layer 1 — API-secret prefix Every sandbox secret begins with `sk_sandbox_`. Production secrets do not. Anyone — human or agent — can grep their `.env` to verify they match the deployed `NODE_ENV`: ```ts if (process.env.TXNOD_API_SECRET?.startsWith('sk_sandbox_')) { console.log('[boot] running with sandbox API secret'); } else { console.log('[boot] running with production API secret'); } export {}; ``` ### Layer 2 — SDK constructor hard-fail When the SDK detects `NODE_ENV === 'production'` (or the explicit `environment: 'production'` constructor option) AND the configured secret starts with `sk_sandbox_`, instantiation throws `TxnodSandboxKeyInProductionError`: ```ts import { TxnodClient, TxnodSandboxKeyInProductionError } from '@txnod/sdk'; try { const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); } catch (err) { if (err instanceof TxnodSandboxKeyInProductionError) { throw new Error('refusing to boot with sandbox secret in production'); } throw err; } ``` The override (`iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true`) exists for legitimate staging-replica setups. It does not suppress the non-suppressible `console.error` on every constructor invocation, and every outbound API request carries an `X-Txnod-Client-Environment: production` header so server-side telemetry surfaces the misuse. ### Layer 3 — SDK xpub-prefix guard When `TXNOD__XPUB` configured for a production project starts with a testnet prefix (`tpub`, `vpub`, `upub`), the SDK throws `TxnodSandboxXpubInProductionError` at boot: ```ts import { TxnodClient, TxnodSandboxXpubInProductionError } from '@txnod/sdk'; try { const client = new TxnodClient({ projectId: process.env.TXNOD_PROJECT_ID!, apiSecret: process.env.TXNOD_API_SECRET!, }); // createInvoice() triggers the xpub-prefix verification when TXNOD__XPUB is set await client.createInvoice({ external_id: 'order-1', coin: 'btc', amount_usd: 5, }); } catch (err) { if (err instanceof TxnodSandboxXpubInProductionError) { throw new Error('refusing to verify against testnet xpub in production'); } throw err; } ``` EVM-family `xpub` prefixes are valid on both mainnet and testnet, so this layer only catches BTC-family testnet prefixes. The other 11 assets are caught by layer 4 and layer 5. ### Layer 4 — Server cross-mode rejection A sandbox API secret targeting a production project (or a production secret targeting a sandbox project) is rejected at the server with `error_code: sandbox_key_against_production_project` or `production_key_against_sandbox_project`. The SDK throws typed errors: ```ts import { TxnodClient, TxnodSandboxKeyAgainstProductionProjectError, TxnodProductionKeyAgainstSandboxProjectError, } from '@txnod/sdk'; declare const client: TxnodClient; declare const productionProjectId: string; try { await client.sandbox.reset(productionProjectId); } catch (err) { if (err instanceof TxnodSandboxKeyAgainstProductionProjectError) { throw new Error('refusing: sandbox key cannot target production project'); } if (err instanceof TxnodProductionKeyAgainstSandboxProjectError) { throw new Error('refusing: production key cannot target sandbox project'); } throw err; } ``` The cross-mode check fires only on the four explicit-`projectId` endpoints (`{projectId}/clock/advance`, `{projectId}/reset`, `DELETE {projectId}`, `{projectId}/wallets`) and on every project-scoped MCP tool. The 10 invoice-scoped simulate endpoints have an implicit project_id (derived from the API key), so the cross-mode check is tautological there and does not fire. ### Layer 5 — Webhook envelope `mode` Every outbound webhook carries `mode: 'production' | 'testnet' | 'sandbox'`. Integrators assert this in their handler so a sandbox event cannot leak into production (and so a testnet event is never confused with a production one): ```ts import { verifyWebhookSignature } from '@txnod/sdk'; export async function POST(request: Request): Promise { const rawBody = await request.text(); const event = verifyWebhookSignature( request.headers, rawBody, process.env.TXNOD_WEBHOOK_SECRET!, ); if (event.mode === 'sandbox' && process.env.NODE_ENV === 'production') { throw new Error('refusing sandbox event in production'); } // ...handler logic... return Response.json({ ok: true }); } ``` When `event.mode === 'sandbox'`, see [Sandbox safety](../guides/sandbox-safety) for the recommended assertion. ### Layer 6 — Documentation / CI assertion The recommended CI assertion (below) is reproduced byte-exact across the SDK-bundled `05-sandbox.md`, this guide, and every example app's `tests/setup.ts`. For the env-mismatch threat model (sandbox secret deployed against production `NODE_ENV`, or vice versa) it's the load-bearing line of defense: it catches the misconfiguration at test boot rather than at deploy time, even if every earlier layer is bypassed. ### Layer 7 — MCP tool cross-tenant guard Every one of the 14 sandbox MCP tools (`sandbox_simulate_*`, `sandbox_clock_advance`, `sandbox_reset`, `sandbox_delete`, `sandbox_list_wallets`) calls `assertSandboxProjectKind(projectId, { ownerUserId })` at the top of its handler. The helper runs a single `SELECT` against the project row and asserts four invariants in one shot: 1. The project row exists. 2. It is not soft-deleted (`deleted_at IS NULL`). 3. Its `kind` is `'sandbox'` (not `'production'`). 4. Its `owner_user_id` matches the calling PAT's `ownerUserId`. Any miss across all four collapses into a single `McpNotFoundError('sandbox project', projectId)` so the calling agent cannot oracle-probe the existence of a production project, another operator's sandbox project, or distinguish "wrong kind" from "wrong owner" by error-message diff. For the cross-tenant threat model this is the load-bearing predicate: PATs minted with the default "all my projects" allowlist mode have `projectAllowlist === null`, which makes the secondary `assertProjectAllowed` check a no-op for those tokens. (PATs explicitly scoped to a project allowlist do hit `assertProjectAllowed` — that's a real second guard whose miss surfaces as a distinct `McpAllowlistError` rather than collapsing into not-found.) The same helper backs the `sandbox:simulate`-scoped MCP tools surfaced via the `@txnod/mcp-stdio` bridge to Claude Desktop / Claude Code / Cursor — the cross-tenant guarantee holds end-to-end from the agent's prompt down to the row-level assertion. Operator A's PAT cannot probe operator B's sandbox project IDs; the protocol surface is structurally indistinguishable from "project never existed". ## What happens if... ### A sandbox API secret is deployed with `NODE_ENV=production` `TxnodClient` instantiation throws `TxnodSandboxKeyInProductionError` at boot. The exact error message: ``` TxnodSandboxKeyInProductionError: Sandbox API secret (sk_sandbox_*) detected with NODE_ENV=production. Set environment: 'non-production' or use a production API secret. To override (staging-replica only), pass iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true. ``` Recovery: replace the deployed `TXNOD_API_SECRET` with the production project's secret, OR set `NODE_ENV=development` (or another non-production value), OR wire the override constructor option (only for legitimate staging-replica setups). The recommended CI assertion catches this at test boot rather than at deploy time. ### A production API secret is used to drive a sandbox project's `reset()` The server returns HTTP 400 with `error_code: production_key_against_sandbox_project`. The SDK throws `TxnodProductionKeyAgainstSandboxProjectError`. The exact error message: ``` TxnodProductionKeyAgainstSandboxProjectError: API key is for a production project but target project is a sandbox project. Cross-mode operations are not permitted. ``` Recovery: use the sandbox PAT (with `sandbox:simulate` scope) for sandbox project operations, not the production API secret. The four cross-mode-checked endpoints are `{projectId}/clock/advance`, `{projectId}/reset`, `DELETE {projectId}`, and `{projectId}/wallets` — invoice-scoped simulate endpoints don't fire this check because the project_id is implicit in the API key. ### A handler doesn't branch on `event.mode` and a sandbox event arrives in production The handler processes the sandbox event as if it were a real payment — fulfilling an order, sending an email, marking inventory shipped. The handler "succeeds" with no error, but the financial side-effect is wrong because no real funds moved. Layer 5 of the defense exists specifically to prevent this; the recommended assertion at the top of every handler: ```ts import type { WebhookEvent } from '@txnod/sdk'; declare const event: WebhookEvent; if (event.mode === 'sandbox' && process.env.NODE_ENV === 'production') { throw new Error('refusing to process sandbox event in production'); } ``` The exact error message your handler should throw is the literal text above so ops can grep logs for the specific phrase. Recovery: confirm `TXNOD_WEBHOOK_SECRET` is the production project's secret (not the sandbox project's) — a misrouted webhook means the wrong secret is signing the inbound envelope. The [two-secret routing helper shown in Agent-driven testing](./agent-driven-testing#how-to-write-the-integration) is the canonical handler shape that surfaces this misconfiguration as a defense-in-depth log line. ## Recommended CI assertion Add this once at the top of your test setup so a misconfiguration breaks CI loudly: ```ts // tests/setup.ts function assertSafeMode(): void { const env = process.env.NODE_ENV ?? 'unknown'; const secret = process.env.TXNOD_API_SECRET ?? ''; const isSandbox = secret.startsWith('sk_sandbox_'); if (env === 'production' && isSandbox) { throw new Error( '[ci] Sandbox API secret cannot run with NODE_ENV=production', ); } if (env !== 'production' && !isSandbox) { throw new Error( '[ci] Production API secret cannot run outside production', ); } } assertSafeMode(); export {}; ``` This block is reproduced byte-exact across the SDK-bundled `05-sandbox.md`, this guide, and the canonical example app's `tests/setup.ts`. An agent or human integrator can grep for the canonical text to verify they're aligned.