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 theX-Project-Idheader).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:
| Status | error_code | Trigger |
|---|---|---|
| 400 | sandbox_key_against_production_project | Sandbox 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. |
| 400 | production_key_against_sandbox_project | Production secret targeting sandbox project. Fires on every sandbox route (invoice CRUD, simulate-*, and the 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 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:
| 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.
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:
| 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.
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:
| Field | Type | Notes |
|---|---|---|
multiplier | number > 1 | Factor applied to the invoice’s amount_crypto_units. |
extraUnits | 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.
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:
| Field | Type | Notes |
|---|---|---|
fraction | number ∈ (0, 1) | Fraction of amount_crypto_units actually received. |
amountUnits | 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.
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:
| Field | Type | Required | Notes |
|---|---|---|---|
eventType | enum of webhook event types | yes | One of invoice.detected, invoice.paid, invoice.overpaid, invoice.partial, invoice.expired, invoice.expired_paid_late, invoice.reverted. |
amountUnits | string | yes | Pattern ^\d+$. |
confirmations | integer | yes | min: 0. |
blockHeight | integer | yes | min: 1. |
chainSpecific | object | no | Chain-namespaced extra fields. |
expectedCurrentStatus | 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.
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"Related
- SDK reference:
client.sandbox.*— the typed client surface. - Sandbox projects — project-creation flow + the agent loop.
- Sandbox safety — seven-layer defense + the recommended CI assertion.
- Webhook event types — every event carries
mode: 'production' | 'testnet' | 'sandbox'.