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 . 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. Thesk_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 wireTXNOD_WEBHOOK_SECRET_SANDBOXas a second env var).- Sandbox PAT — Personal Access Token carrying the
sandbox:simulatescope. 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'/ownerUserIdmatch) that collapses any miss into a single not-found surface — see Layer 7.
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 instantiateTxnodClientwith a sandbox secret in a production-detected environment unless you setiAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true(and even then it logs a non-suppressibleconsole.errorand flags every request withX-Txnod-Client-Environment: production). - Independent webhook secret —
TXNOD_WEBHOOK_SECRETfor the project. Distinct from the API secret and from any production project’s webhook secret. Sample integrations declare bothTXNOD_WEBHOOK_SECRETandTXNOD_WEBHOOK_SECRET_SANDBOXso a single deploy can verify inbound webhooks from either mode. - Sandbox PAT —
sandbox:simulatescope. 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 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:
import { TxnodClient } from '@txnod/sdk';
declare const beforeAll: (fn: () => Promise<void>) => void;
declare const afterAll: (fn: () => Promise<void>) => 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, theprojects.kindcolumn, the webhook envelope’smodediscriminator, 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:
- API-secret prefix. Every sandbox secret begins with
sk_sandbox_. Production secrets use a different (production) prefix. Anyone — human or agent — can grep their.envfiles for the prefix to verify they match the deployedNODE_ENV. - SDK constructor hard-fail. When the SDK detects
NODE_ENV === 'production'(or the explicitenvironment: 'production'constructor option) AND the configured secret starts withsk_sandbox_, instantiation throwsTxnodSandboxKeyInProductionError. Override exists (iAcknowledgeRoutingRealCustomerFundsToSandboxAddresses: true) but is deliberately verbose, logs a non-suppressibleconsole.error, and flags every request withX-Txnod-Client-Environment: production. - SDK xpub-prefix guard. When
TXNOD_<chain>_XPUBconfigured for a production project starts with a testnet prefix (tpub,vpub,upub), the SDK throwsTxnodSandboxXpubInProductionErrorat boot. (Carve-out: EVM-familyxpubprefixes are valid on both mainnet and testnet; this layer only catches BTC-family testnet prefixes.) - 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) orproduction_key_against_sandbox_project. The SDK throwsTxnodSandboxKeyAgainstProductionProjectError/TxnodProductionKeyAgainstSandboxProjectError. - Webhook envelope
mode. Every outbound webhook carriesmode: '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');. - 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 standalone reference, and every example app’stests/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:
// 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.
Graduate to production
When you’re ready to ship to mainnet:
- 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.
- Create a production project with a non-
sk_sandbox_API secret. The dashboard’s+ New projectbutton (not the sandbox button) provisions the project shell. - Swap
sk_sandbox_...for the production secret in your deployed.env. Keep the sandbox secret in your local dev.env.localso you can keep running the test suite during development. - Drop your
event.mode === 'sandbox'assertions that gated test-only behavior — production webhooks carrymode: 'production', so the assertion now fails-closed if a sandbox event ever leaks into production. - Re-read 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.