Skip to Content
API ReferenceSandbox simulation API

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 — the API Key ID (sent in the X-Project-Id header).
  • X-Timestamp — current Unix timestamp (seconds), ±300-second window.
  • X-Signature — HMAC-SHA256 hex digest of ${method}\n${pathname}\n${timestamp}\n${rawBody} using the sandbox API secret (sk_sandbox_...). Binding method + path makes a captured (X-Timestamp, X-Signature) pair non-replayable across endpoints.

The SDK (client.sandbox.* per SDK reference) 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. Every sandbox route carries the cross-mode guard; the reachable rejection direction is a production secret bound to a sandbox project (production_key_against_sandbox_project) — a sandbox secret bound to a production project short-circuits earlier at the kind guard with sandbox_project_required (403).

The sandbox invoice CRUD routes below mirror the production Invoices API, which is production-kind-only — a sandbox project calling /api/v1/invoices is rejected with production_project_required. Create, read, list, and cancel sandbox invoices through /api/v1/sandbox/invoices instead.

Error taxonomy

Every sandbox endpoint can return any of the following sandbox-specific error codes:

Statuserror_codeTrigger
400sandbox_key_against_production_projectSandbox secret targeting production project. On sandbox routes this direction short-circuits earlier at the kind guard (sandbox_project_required); the code is reachable on the production-only routes.
400production_key_against_sandbox_projectProduction secret targeting sandbox project. Fires on every sandbox route (invoice CRUD, simulate-*, and the explicit-projectId endpoints).
403sandbox_project_requiredEndpoint reached with non-sandbox project id (i.e. projects.kind is not 'sandbox').
404sandbox_invoice_not_foundInvoice id does not belong to the project.
422sandbox_invoice_transition_invalidCurrent invoice status does not permit the requested simulation.
422sandbox_invoice_terminalsimulate-duplicate-delivery invoked but no terminal event exists yet.
429sandbox_rate_limit_exceededPer-project simulate-* rate cap (per chain) exceeded.
500sandbox_reset_failedInternal error during reset() cascade.
500sandbox_delete_failedInternal 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 CRUD routes

The sandbox mirror of the production Invoices API. Same request/response schemas, same idempotency contract; differences: no subscription enforcement (sandbox projects are unbilled), a flat 60/min per-project write rate limit, and the sandbox 10k active-invoice cap on creation.

POST /sandbox/invoices

Creates an invoice on the sandbox project. Request body and response schema are identical to POST /invoices — idempotent by (project_id, external_id); a replayed request returns the existing invoice with HTTP 200 instead of 201.

Errors: coin_not_enabled (422), sandbox_active_invoice_cap_reached (422 — call POST /sandbox/{projectId}/reset), pool_exhausted (503 + Retry-After), rate_quote_unavailable (503 + Retry-After), validation_error, rate_limit_exceeded.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices" \ -H "X-Project-Id: $TXNOD_API_KEY_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE" \ -H "Content-Type: application/json" \ -d '{"external_id": "order-42", "coin": "usdt_trc20", "amount_usd": 9.99}'

GET /sandbox/invoices

Cursor-paginated search over the sandbox project’s invoices. Same query filters and response envelope as GET /invoices.

GET /sandbox/invoices/{invoiceId}

Fetch a single sandbox invoice by ULID, including transactions + confirmations. Returns 404 invoice_not_found on cross-project lookups (no enumeration).

POST /sandbox/invoices/{invoiceId}/cancel

Cancels an invoice in pending or detected state and releases its pool address into cooldown. Terminal-status invoices return 409 invoice_not_cancellable.

Webhook delivery-log routes

The sandbox mirror of the production Webhooks API, which is production-kind-only — a sandbox project calling /api/v1/webhooks/events is rejected with production_project_required. Every row corresponds to an envelope delivered with mode: 'sandbox'.

GET /sandbox/webhooks/events

Cursor-paginated delivery log of outbound webhook events for the sandbox project. Same query filters (status, event_type, since, invoice_id, cursor, limit) and response envelope as GET /webhooks/events.

curl "https://txnod.com/api/v1/sandbox/webhooks/events?invoice_id=$INVOICE_ID&limit=50" \ -H "X-Project-Id: $TXNOD_API_KEY_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE"

POST /sandbox/webhooks/events/{eventId}/resend

Resends a previously-delivered sandbox webhook event with a fresh event_id (the response carries original_event_id for lineage). Same semantics as POST /webhooks/events/{event_id}/resend: 202 Accepted, 404 event_not_found on cross-project lookups, 409 event_not_resendable for in-flight or dead-lettered events.

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:

FieldTypeNotes
seedstringDeterministic seed for the synthesized tx_hash. Same seed → same hash.

Response 200:

FieldType
event_idstring ULID
status'detected'

Errors: sandbox_invoice_not_found, sandbox_invoice_transition_invalid (invoice not in pending), sandbox_rate_limit_exceeded.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-detect" \ -H "X-Project-Id: $TXNOD_API_KEY_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:

FieldType
event_idstring ULID
status'paid'

Errors: sandbox_invoice_not_found, sandbox_invoice_transition_invalid (invoice not in detected), sandbox_rate_limit_exceeded.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-paid" \ -H "X-Project-Id: $TXNOD_API_KEY_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 extraUnits (chain-native smallest-unit string).

Request body — exactly one of:

FieldTypeNotes
multipliernumber > 1Factor applied to the invoice’s amount_crypto_units.
extraUnitsstringAdditional 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.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-overpaid" \ -H "X-Project-Id: $TXNOD_API_KEY_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 amountUnits.

Request body — exactly one of:

FieldTypeNotes
fractionnumber ∈ (0, 1)Fraction of amount_crypto_units actually received.
amountUnitsstringExplicit 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.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-partial" \ -H "X-Project-Id: $TXNOD_API_KEY_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.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-expire" \ -H "X-Project-Id: $TXNOD_API_KEY_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.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-late-payment" \ -H "X-Project-Id: $TXNOD_API_KEY_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.

curl -X POST "https://txnod.com/api/v1/sandbox/invoices/$INVOICE_ID/simulate-reorg" \ -H "X-Project-Id: $TXNOD_API_KEY_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 new event_id (not a replay of the original) — exercises the dispatcher’s restore-after-revert contract: the re-emitted event must escape your UNIQUE(event_id) dedupe.

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 eventType, amountUnits, confirmations, blockHeight, optional chainSpecific payload, the expectedCurrentStatus precondition (rejects the call if the invoice is not in this status), and an optional seed.

Request body:

FieldTypeRequiredNotes
eventTypeenum of webhook event typesyesOne of invoice.detected, invoice.paid, invoice.overpaid, invoice.partial, invoice.expired, invoice.expired_paid_late, invoice.reverted.
amountUnitsstringyesPattern ^\d+$.
confirmationsintegeryesmin: 0.
blockHeightintegeryesmin: 1.
chainSpecificobjectnoChain-namespaced extra fields.
expectedCurrentStatusinvoice status enumyesPrecondition; the simulate call rejects unless the invoice is currently in this status.
seedstringnoDeterministic 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:

FieldTypeRequiredNotes
chainenumyesOne of btc, eth, tron, ada, polygon, bsc, ton.
blocksintegeryesmin: 1; max: 64.

Response 200:

FieldType
advancedinteger (count of invoices whose status changed)
remaininginteger (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.

curl -X POST "https://txnod.com/api/v1/sandbox/$PROJECT_ID/clock/advance" \ -H "X-Project-Id: $TXNOD_API_KEY_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.

curl -X POST "https://txnod.com/api/v1/sandbox/$PROJECT_ID/reset" \ -H "X-Project-Id: $TXNOD_API_KEY_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.

curl -X DELETE "https://txnod.com/api/v1/sandbox/$PROJECT_ID" \ -H "X-Project-Id: $TXNOD_API_KEY_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.

curl -X GET "https://txnod.com/api/v1/sandbox/$PROJECT_ID/wallets" \ -H "X-Project-Id: $TXNOD_API_KEY_ID" \ -H "X-Timestamp: $(date +%s)" \ -H "X-Signature: $SIGNATURE"