@txnod/sdk 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +29 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/_shared/index.d.ts +68 -0
- package/dist/client-sandbox.d.ts +396 -0
- package/dist/client-sandbox.d.ts.map +1 -0
- package/dist/client-sandbox.js +448 -0
- package/dist/client-sandbox.js.map +1 -0
- package/dist/client.d.ts +429 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +588 -0
- package/dist/client.js.map +1 -0
- package/dist/env.d.ts +29 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +44 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +1887 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +2107 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/internals/error-ctor-map.d.ts +11 -0
- package/dist/internals/error-ctor-map.d.ts.map +1 -0
- package/dist/internals/error-ctor-map.js +75 -0
- package/dist/internals/error-ctor-map.js.map +1 -0
- package/dist/internals/fetch-with-retry.d.ts +34 -0
- package/dist/internals/fetch-with-retry.d.ts.map +1 -0
- package/dist/internals/fetch-with-retry.js +233 -0
- package/dist/internals/fetch-with-retry.js.map +1 -0
- package/dist/internals/hmac.d.ts +2 -0
- package/dist/internals/hmac.d.ts.map +1 -0
- package/dist/internals/hmac.js +10 -0
- package/dist/internals/hmac.js.map +1 -0
- package/dist/internals/logger.d.ts +9 -0
- package/dist/internals/logger.d.ts.map +1 -0
- package/dist/internals/logger.js +16 -0
- package/dist/internals/logger.js.map +1 -0
- package/dist/internals/parse-problem-details.d.ts +3 -0
- package/dist/internals/parse-problem-details.d.ts.map +1 -0
- package/dist/internals/parse-problem-details.js +76 -0
- package/dist/internals/parse-problem-details.js.map +1 -0
- package/dist/internals/synthetic-details.d.ts +12 -0
- package/dist/internals/synthetic-details.d.ts.map +1 -0
- package/dist/internals/synthetic-details.js +19 -0
- package/dist/internals/synthetic-details.js.map +1 -0
- package/dist/verify/chains/bsc.d.ts +17 -0
- package/dist/verify/chains/bsc.d.ts.map +1 -0
- package/dist/verify/chains/bsc.js +15 -0
- package/dist/verify/chains/bsc.js.map +1 -0
- package/dist/verify/chains/btc.d.ts +22 -0
- package/dist/verify/chains/btc.d.ts.map +1 -0
- package/dist/verify/chains/btc.js +55 -0
- package/dist/verify/chains/btc.js.map +1 -0
- package/dist/verify/chains/cardano.d.ts +73 -0
- package/dist/verify/chains/cardano.d.ts.map +1 -0
- package/dist/verify/chains/cardano.js +175 -0
- package/dist/verify/chains/cardano.js.map +1 -0
- package/dist/verify/chains/evm.d.ts +21 -0
- package/dist/verify/chains/evm.d.ts.map +1 -0
- package/dist/verify/chains/evm.js +46 -0
- package/dist/verify/chains/evm.js.map +1 -0
- package/dist/verify/chains/polygon.d.ts +17 -0
- package/dist/verify/chains/polygon.d.ts.map +1 -0
- package/dist/verify/chains/polygon.js +15 -0
- package/dist/verify/chains/polygon.js.map +1 -0
- package/dist/verify/chains/secp256k1-bip32.d.ts +20 -0
- package/dist/verify/chains/secp256k1-bip32.d.ts.map +1 -0
- package/dist/verify/chains/secp256k1-bip32.js +88 -0
- package/dist/verify/chains/secp256k1-bip32.js.map +1 -0
- package/dist/verify/chains/ton-cell.d.ts +179 -0
- package/dist/verify/chains/ton-cell.d.ts.map +1 -0
- package/dist/verify/chains/ton-cell.js +614 -0
- package/dist/verify/chains/ton-cell.js.map +1 -0
- package/dist/verify/chains/ton.d.ts +84 -0
- package/dist/verify/chains/ton.d.ts.map +1 -0
- package/dist/verify/chains/ton.js +131 -0
- package/dist/verify/chains/ton.js.map +1 -0
- package/dist/verify/chains/tron.d.ts +21 -0
- package/dist/verify/chains/tron.d.ts.map +1 -0
- package/dist/verify/chains/tron.js +42 -0
- package/dist/verify/chains/tron.js.map +1 -0
- package/dist/verify/config.d.ts +41 -0
- package/dist/verify/config.d.ts.map +1 -0
- package/dist/verify/config.js +120 -0
- package/dist/verify/config.js.map +1 -0
- package/dist/verify/errors.d.ts +56 -0
- package/dist/verify/errors.d.ts.map +1 -0
- package/dist/verify/errors.js +58 -0
- package/dist/verify/errors.js.map +1 -0
- package/dist/verify/index.d.ts +119 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +166 -0
- package/dist/verify/index.js.map +1 -0
- package/dist/verify/xpub-safety.d.ts +33 -0
- package/dist/verify/xpub-safety.d.ts.map +1 -0
- package/dist/verify/xpub-safety.js +54 -0
- package/dist/verify/xpub-safety.js.map +1 -0
- package/dist/verify-webhook-signature.d.ts +30 -0
- package/dist/verify-webhook-signature.d.ts.map +1 -0
- package/dist/verify-webhook-signature.js +84 -0
- package/dist/verify-webhook-signature.js.map +1 -0
- package/docs/00-getting-started.md +135 -0
- package/docs/01-authentication.md +114 -0
- package/docs/02-invoices.md +216 -0
- package/docs/03-rates-and-quotes.md +82 -0
- package/docs/04-webhooks.md +126 -0
- package/docs/05-errors.md +199 -0
- package/docs/05-sandbox.md +159 -0
- package/docs/06-idempotency.md +132 -0
- package/docs/examples/express-webhook-receiver.md +97 -0
- package/docs/examples/nextjs-route-handler.md +206 -0
- package/docs/examples/sandbox-vitest-suite.md +263 -0
- package/docs/index.md +66 -0
- package/docs/reference/client.md +392 -0
- package/docs/reference/errors.md +161 -0
- package/docs/reference/types.md +400 -0
- package/package.json +53 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Authentication"
|
|
3
|
+
description: "HMAC-SHA256 scheme for outbound API calls: headers, body canonicalization, ±300-second window, key rotation."
|
|
4
|
+
sdk_version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Authentication
|
|
8
|
+
|
|
9
|
+
Every `TxnodClient` method signs outbound requests automatically. You pass `projectId` and `apiSecret` to the constructor; the SDK takes care of headers, timestamps, HMAC, and retries. You only need to understand the scheme when debugging a `TxnodAuthInvalidError` or when building a non-TS client.
|
|
10
|
+
|
|
11
|
+
The scheme below is **endpoint-agnostic** — it works identically against `https://txnod.com` (the default), a staging deployment, or any self-hosted txnod-compatible API the partner points `baseUrl` at. The server origin only changes where the signed bytes travel; the signing contract is the same.
|
|
12
|
+
|
|
13
|
+
## Scheme
|
|
14
|
+
|
|
15
|
+
Three request headers are required on every call:
|
|
16
|
+
|
|
17
|
+
| Header | Value |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `X-Project-Id` | ULID of the calling project (your `TXNOD_PROJECT_ID`) |
|
|
20
|
+
| `X-Timestamp` | Current unix seconds (integer, as a string) |
|
|
21
|
+
| `X-Signature` | 64-char lowercase hex HMAC-SHA256 |
|
|
22
|
+
|
|
23
|
+
The signature is computed over the canonical bytes `${method}\n${pathname}\n${timestamp}\n${rawBody}` using `apiSecret` as the key. Method and pathname are bound into the input so an intercepted `(X-Timestamp, X-Signature)` pair cannot be replayed against a different endpoint of the same project. For `GET` / body-less `POST`, `rawBody` is the empty string.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// What the SDK does internally — you do NOT need to reimplement this when
|
|
27
|
+
// you use TxnodClient. This is the exact contract a non-TS client must mirror.
|
|
28
|
+
import { createHmac } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
const method = 'POST'; // uppercase HTTP verb
|
|
31
|
+
const pathname = '/api/v1/invoices'; // URL pathname only — query string is NOT signed
|
|
32
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
33
|
+
const rawBody = JSON.stringify(payload); // exact bytes you put on the wire
|
|
34
|
+
const signature = createHmac('sha256', apiSecret)
|
|
35
|
+
.update(`${method}\n${pathname}\n${timestamp}\n${rawBody}`)
|
|
36
|
+
.digest('hex');
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> **Note — outbound API vs inbound webhook.** The outbound scheme above (`method\npathname\ntimestamp\nbody`) is what `TxnodClient` uses for every API call. The **inbound** webhook scheme is different — `verifyWebhookSignature` HMACs `${timestamp}.${rawBody}` because the webhook body byte stream is the only varying input. Do not mix the two formulas.
|
|
40
|
+
|
|
41
|
+
## Clock skew window
|
|
42
|
+
|
|
43
|
+
The server rejects requests whose `X-Timestamp` is outside `±300` seconds of server time. The SDK always stamps `Date.now()` at send time, so the only failure mode in practice is host clock drift. The server raises `TxnodTimestampOutOfWindowError` (HTTP 401, `error_code: timestamp_out_of_window`) — check NTP on the calling host.
|
|
44
|
+
|
|
45
|
+
## Body canonicalization
|
|
46
|
+
|
|
47
|
+
The SDK serializes request bodies with `JSON.stringify` and signs the exact bytes it puts on the wire. There is no field-order canonicalization — the HMAC covers the literal byte stream. Do not mutate a body between the SDK handing it to `fetch` and the request leaving the host (this is only a concern if you proxy or rewrite).
|
|
48
|
+
|
|
49
|
+
## Retry behaviour
|
|
50
|
+
|
|
51
|
+
`signedFetch` retries automatically:
|
|
52
|
+
|
|
53
|
+
- `429`: up to 3 retries, honouring `Retry-After` header (seconds). Backoff is `max(Retry-After, fullJitterExponential)` with base 500 ms and cap 30 s. `Retry-After` is itself capped at 600 s — defends against a hostile or buggy upstream returning a far-future date.
|
|
54
|
+
- `5xx`: GET retries on every 5xx (up to 2). POST only retries on **503** (`pool_exhausted`, server overloaded — server has not yet committed any state) and **504** (gateway timeout — request did not reach the application). 500/502 on POST surface immediately, because the server may have already processed the request before failing and re-issuing risks duplication.
|
|
55
|
+
- Each retry re-signs with a fresh `timestamp`.
|
|
56
|
+
|
|
57
|
+
After retries are exhausted, the response is returned to the caller and, if still non-2xx, mapped to a typed `TxnodError` (see [`05-errors.md`](./05-errors.md)).
|
|
58
|
+
|
|
59
|
+
### Per-attempt timeout and body-size cap
|
|
60
|
+
|
|
61
|
+
`TxnodClient` accepts two safety bounds:
|
|
62
|
+
|
|
63
|
+
- `requestTimeoutMs` (default `30_000`) — per-attempt HTTP timeout via `AbortSignal.timeout`. Each retry gets its own clock.
|
|
64
|
+
- `maxResponseBytes` (default `1 MiB`) — hard cap on response body size. Over-cap responses are surfaced as a typed 502 with `error_code: 'internal_error'` so the SDK never buffers an unbounded body from a hostile upstream.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const client = new TxnodClient({
|
|
68
|
+
projectId: process.env.TXNOD_PROJECT_ID!,
|
|
69
|
+
apiSecret: process.env.TXNOD_API_SECRET!,
|
|
70
|
+
requestTimeoutMs: 15_000, // optional
|
|
71
|
+
maxResponseBytes: 256 * 1024, // optional
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Request logger
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const client = new TxnodClient({
|
|
79
|
+
/* ... */
|
|
80
|
+
requestLogger: (entry) => {
|
|
81
|
+
// entry.method | entry.path | entry.status | entry.durationMs | entry.attempt | entry.requestId
|
|
82
|
+
log.info('txnod', entry);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
One entry per HTTP attempt (success or failure, including each retry). Errors thrown from the logger are swallowed by the SDK so a buggy logger never fails a request.
|
|
88
|
+
|
|
89
|
+
## Key rotation
|
|
90
|
+
|
|
91
|
+
1. Generate a new key in the dashboard. The old key stays valid until you revoke it.
|
|
92
|
+
2. Roll `TXNOD_API_SECRET` (and `TXNOD_WEBHOOK_SECRET`, which is the same value) in your secrets manager.
|
|
93
|
+
3. Restart the application so the new value is picked up.
|
|
94
|
+
4. Revoke the old key in the dashboard.
|
|
95
|
+
|
|
96
|
+
When the server sees a request signed by a revoked key, it raises `TxnodKeyRevokedError` (HTTP 401, `error_code: key_revoked`). Treat as fatal — do not retry with the same secret.
|
|
97
|
+
|
|
98
|
+
## Common failure modes
|
|
99
|
+
|
|
100
|
+
| Symptom | Likely cause |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `TxnodAuthInvalidError` on every call | `TXNOD_PROJECT_ID` does not match a project owned by the key, or headers are absent |
|
|
103
|
+
| `TxnodSignatureInvalidError` | Wrong `apiSecret`, or a proxy is mutating the request body after signing |
|
|
104
|
+
| `TxnodTimestampOutOfWindowError` | Host clock drift > 300 s — fix NTP |
|
|
105
|
+
| `TxnodKeySuspendedError` / `TxnodProjectSuspendedError` | Operator action; contact support |
|
|
106
|
+
| `TxnodKeyRevokedError` (HTTP 403) | Key was rotated; pick up the new secret |
|
|
107
|
+
| `TxnodSubscriptionExpiredError` (HTTP 402) | Operator's TxNod subscription is not `active`; writes are gated until the operator renews via the dashboard `/billing` page. Reads keep working — surface the failure to the operator out-of-band rather than auto-retrying |
|
|
108
|
+
|
|
109
|
+
See [`05-errors.md`](./05-errors.md) for how to branch on each.
|
|
110
|
+
|
|
111
|
+
## Related
|
|
112
|
+
|
|
113
|
+
- [`02-invoices.md`](./02-invoices.md) — what the authenticated calls do
|
|
114
|
+
- [`04-webhooks.md`](./04-webhooks.md) — inbound HMAC uses the **same secret** and the same HMAC primitive but with an `X-Txnod-Signature: t=<unix>,v1=<hex>` header shape
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Invoices"
|
|
3
|
+
description: "Create, fetch, search, and cancel invoices; the nine-state invoice lifecycle."
|
|
4
|
+
sdk_version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Invoices
|
|
8
|
+
|
|
9
|
+
## Lifecycle
|
|
10
|
+
|
|
11
|
+
An invoice moves through a state machine. Statuses are the `InvoiceStatus` union:
|
|
12
|
+
|
|
13
|
+
| Status | Meaning | Terminal? |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `pending` | Created, awaiting on-chain receipt | no |
|
|
16
|
+
| `detected` | On-chain receipt seen, below confirmation threshold | no |
|
|
17
|
+
| `paid` | Meets confirmation threshold AND covers `amount_crypto` | **no, reorg-reversible** |
|
|
18
|
+
| `overpaid` | Paid with strictly more than `amount_crypto` | **no, reorg-reversible** |
|
|
19
|
+
| `partial` | Detected under `amount_crypto`; waiting for additional deposits | no |
|
|
20
|
+
| `expired` | `expires_at` passed before reaching paid | yes |
|
|
21
|
+
| `expired_paid_late` | Payment arrived after `expires_at` | yes |
|
|
22
|
+
| `reverted` | Previously `paid`/`overpaid` but dropped by a chain reorg | yes |
|
|
23
|
+
| `cancelled` | Explicitly cancelled via `cancelInvoice` | yes |
|
|
24
|
+
|
|
25
|
+
**`paid` is not the end of the story.** A deeper reorg can still invalidate on-chain receipts. Only trust `paid` / `overpaid` for fulfilment once `confirmations` ≥ your project's finalization threshold, and always have a path to roll back on an `invoice.reverted` webhook — see [`06-idempotency.md`](./06-idempotency.md).
|
|
26
|
+
|
|
27
|
+
## `createInvoice(body)`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const invoice = await client.createInvoice({
|
|
31
|
+
external_id: 'order-42', // required, 1..128 chars, unique per project
|
|
32
|
+
coin: 'usdt_trc20', // required — see reference/types.md#coin
|
|
33
|
+
amount_usd: 9.99, // EITHER amount_usd OR amount_crypto (not both)
|
|
34
|
+
// amount_crypto: '9.990000',
|
|
35
|
+
callback_url: 'https://your-site.com/api/txnod-webhook', // optional
|
|
36
|
+
metadata: { order_ref: '42' }, // optional free-form JSON
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Response (selected fields, full shape in [`reference/types.md`](./reference/types.md#invoiceresponse)):
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
invoice.id // ULID, txnod-side primary key
|
|
44
|
+
invoice.external_id // echoed back
|
|
45
|
+
invoice.address // deposit address, never reused across invoices
|
|
46
|
+
invoice.amount_crypto // decimal string (matches the coin's display precision)
|
|
47
|
+
invoice.amount_crypto_units // integer string in smallest unit (sats, wei, lovelace, ...)
|
|
48
|
+
invoice.payment_uri // BIP-21 / EIP-681 / Cardano URI for rendering a QR
|
|
49
|
+
invoice.status // 'pending' on creation
|
|
50
|
+
invoice.rate_snapshot // null if amount_crypto was passed; otherwise binding rate
|
|
51
|
+
invoice.expires_at_iso // ISO-8601 UTC
|
|
52
|
+
invoice.matching_mode // 'exact' | 'at_least' | 'any' — governs partial/overpaid classification
|
|
53
|
+
invoice.confirmation_threshold // per-chain default, configurable in dashboard
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Idempotency
|
|
57
|
+
|
|
58
|
+
The pair `(project_id, external_id)` is `UNIQUE`. A second `createInvoice` with the same `external_id` returns `TxnodExternalIdConflictError` — treat it as idempotent success and fetch the existing invoice:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { TxnodClient, TxnodExternalIdConflictError } from '@txnod/sdk';
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return await client.createInvoice({ external_id, coin, amount_usd });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof TxnodExternalIdConflictError) {
|
|
67
|
+
const page = await client.searchInvoices({ external_id, limit: 1 });
|
|
68
|
+
return page.items[0]!;
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### USD vs crypto amount
|
|
75
|
+
|
|
76
|
+
Exactly one of `amount_usd` / `amount_crypto` must be provided.
|
|
77
|
+
|
|
78
|
+
- `amount_usd`: TxNod computes the binding crypto amount at creation using the current CoinGecko rate. `rate_snapshot` is populated with the rate, source, and age. Any subsequent market move is not reflected — **the invoice amount is fixed at creation**.
|
|
79
|
+
- `amount_crypto`: you set the crypto amount directly. `rate_snapshot` is `null`, `amount_usd` is `null`. Use this if you price in crypto upstream.
|
|
80
|
+
|
|
81
|
+
For USD-priced integrations, call [`quoteAmount`](./03-rates-and-quotes.md) before creation if you want to preview the amount (indicative only — binding rate is still captured at `createInvoice` time).
|
|
82
|
+
|
|
83
|
+
### Pool exhaustion (503)
|
|
84
|
+
|
|
85
|
+
`createInvoice` allocates an address from a per-chain pool bounded by the project's `poolSizePerChain[chain]` (default 20 — matches the BIP-44 / CIP-1852 gap-limit of 20 so a recovery wallet still rediscovers every deposit). If the pool is already at cap with no `available` slot, the server returns `503` `pool_exhausted` with a `Retry-After` header and the SDK throws [`TxnodPoolExhaustedError`](./05-errors.md) after its internal retry budget is exhausted. `err.retry_after_seconds` carries the header value (server-computed from the soonest `locked_until` or project cooldown); wait that interval then retry, or raise the cap from the operator dashboard if the error recurs under normal load.
|
|
86
|
+
|
|
87
|
+
### TRON activation error
|
|
88
|
+
|
|
89
|
+
TRON is the only chain that requires a recipient address to be "activated" with at least 1 TRX (or 1 USDT-TRC20) before it can hold funds. TxNod handles activation on the operator side via the dashboard. Until the operator's TRON wallet has at least one activated row in its address pool, `createInvoice({ coin: 'trx' | 'usdt_trc20' })` raises [`TxnodTronNoActivatedAddressesError`](./05-errors.md) (HTTP 422, `error_code: tron_no_activated_addresses_available`). This is operator-action-required and not transient — do not auto-retry.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { TxnodClient, TxnodTronNoActivatedAddressesError, TxnodError } from '@txnod/sdk';
|
|
93
|
+
|
|
94
|
+
const client = new TxnodClient({
|
|
95
|
+
projectId: process.env.TXNOD_PROJECT_ID!,
|
|
96
|
+
apiSecret: process.env.TXNOD_API_SECRET!,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const invoice = await client.createInvoice({
|
|
101
|
+
external_id: 'order-1',
|
|
102
|
+
amount_usd: 9.99,
|
|
103
|
+
coin: 'usdt_trc20',
|
|
104
|
+
callback_url: 'https://your-site.com/api/txnod-webhook',
|
|
105
|
+
});
|
|
106
|
+
console.log(invoice.id);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err instanceof TxnodTronNoActivatedAddressesError) {
|
|
109
|
+
// Surface this to the user — the operator must complete TRON setup before
|
|
110
|
+
// TRON invoices succeed. err.walletId is the operator wallet id, useful
|
|
111
|
+
// for in-app deep-links if your UI knows the operator dashboard URL.
|
|
112
|
+
console.error('TRON not ready, walletId =', err.walletId, 'request_id =', err.request_id);
|
|
113
|
+
throw err; // not retryable
|
|
114
|
+
}
|
|
115
|
+
if (err instanceof TxnodError) console.error(err.error_code, err.request_id);
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The `walletId` field is guaranteed non-empty (the SDK constructor fails fast if the server omits it). Treat the error as a structural setup gap on the operator's side — fall back to a non-TRON coin or block the user's checkout until the operator activates their TRON pool.
|
|
121
|
+
|
|
122
|
+
### Testnet
|
|
123
|
+
|
|
124
|
+
Testnet runs are project-scoped, not request-scoped: the operator creates a separate `kind='testnet'` project (via the dashboard) and binds testnet-only wallets to it. Once that's done the SDK code path is identical to production — invoice creation has no `network` knob, so partner code stays the same. The `kind` is a property of the project, not of each invoice.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// Same code regardless of kind. Talk to the testnet project's API key
|
|
128
|
+
// and the matching invoices get testnet addresses + testnet confirmation
|
|
129
|
+
// cadence.
|
|
130
|
+
const invoice = await client.createInvoice({
|
|
131
|
+
external_id: 'staging-order-1',
|
|
132
|
+
amount_usd: 5,
|
|
133
|
+
coin: 'btc',
|
|
134
|
+
callback_url: 'https://staging.example.com/api/txnod-webhook',
|
|
135
|
+
});
|
|
136
|
+
console.log(invoice.address); // testnet-prefix address (e.g. 'tb1...' for BTC)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The matching operator wallet must be registered with a testnet-prefix xpub (`tpub`/`vpub`/`zpub` for BTC, `tpub` for ETH/Polygon/BSC/TRON, `addr_test1...` stake for Cardano). The SDK's automatic address verification (`createInvoice` → `verifyAddress`) accepts those prefixes whenever the project is `kind='testnet'` — no flag, no separate setup. Use a public testnet faucet to fund the deposit address. Webhooks, finalization thresholds, and idempotency behave the same as production; only the chain and confirmation cadence differ.
|
|
140
|
+
|
|
141
|
+
### Address verification
|
|
142
|
+
|
|
143
|
+
When `TXNOD_<chain>_XPUB` is set in the environment, the SDK automatically derives the expected receive address from the configured xpub at the invoice's `derivation_path` and compares it to the server-returned `invoice.address`. On mismatch `createInvoice` throws `AddressVerificationError` (not a `TxnodError` subclass; the verification happens client-side after the HTTP call). This is the SDK's defense against a compromised TxNod backend silently swapping the deposit address. Set the env var per chain you accept; omit it to skip verification on that chain.
|
|
144
|
+
|
|
145
|
+
**Multiple xpubs (wallet rotation).** Each `TXNOD_<chain>_XPUB` value can be a single xpub, a comma-separated newest-first list, or a JSON array of strings. Verification iterates newest-first and accepts on the first match.
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
TXNOD_BTC_XPUB=zpub_new,zpub_old
|
|
149
|
+
# equivalent JSON-array form:
|
|
150
|
+
TXNOD_BTC_XPUB=["zpub_new","zpub_old"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**TON has no xpub** — its address is derived from `(walletPubkey, walletVersion, subwalletId, workchain)`. Configure four env vars; only `TXNOD_TON_PUBKEY` is required:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
TXNOD_TON_PUBKEY=<64-hex Ed25519 pubkey from operator's wallet device>
|
|
157
|
+
TXNOD_TON_WALLET_VERSION=v4R2 # default; alternatives: v3R2, v5R1
|
|
158
|
+
TXNOD_TON_SUBWALLET_ID=698983191 # default; standard for v3R2/v4R2
|
|
159
|
+
TXNOD_TON_WORKCHAIN=0 # default; basechain. -1 = masterchain
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The pubkey is the public Ed25519 key the operator's wallet device exports — never the secret key. The SDK is non-custodial by construction.
|
|
163
|
+
|
|
164
|
+
**Wallet rotation in long-lived processes.** After changing an env var without restarting the worker, call `client.refreshXpubConfig()` to pick up the new value.
|
|
165
|
+
|
|
166
|
+
**Testnet.** Use the testnet-prefixed xpub variant (`tpub`/`vpub`/`zpub`); the verifier accepts both prefix styles whenever the project's `kind` is `'testnet'`.
|
|
167
|
+
|
|
168
|
+
## `getInvoice(id)`
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const invoice = await client.getInvoice(id); // 01HK8MAR2QEXAMPLE000000000
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Raises `TxnodInvoiceNotFoundError` if the id does not belong to the calling project. Use the ULID from `createInvoice`, not `external_id`.
|
|
175
|
+
|
|
176
|
+
## `searchInvoices(query)`
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const page = await client.searchInvoices({
|
|
180
|
+
status: 'paid', // optional; any InvoiceStatus
|
|
181
|
+
external_id: 'order-42', // optional
|
|
182
|
+
address: '...', // optional
|
|
183
|
+
tx_hash: '...', // optional
|
|
184
|
+
date_from: '2026-04-01T00:00:00Z',
|
|
185
|
+
date_to: '2026-04-22T00:00:00Z',
|
|
186
|
+
cursor: undefined, // pass page.next_cursor for the next page
|
|
187
|
+
limit: 50, // 1..200, default 50
|
|
188
|
+
});
|
|
189
|
+
for (const invoice of page.items) { /* ... */ }
|
|
190
|
+
if (page.next_cursor !== undefined) { /* call again with cursor: page.next_cursor */ }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Pagination is cursor-based. `next_cursor` is `undefined` on the last page.
|
|
194
|
+
|
|
195
|
+
`network` is no longer part of the query — invoices belong to a single project, and that project has a fixed `kind`, so there is nothing to disambiguate per-call.
|
|
196
|
+
|
|
197
|
+
## `cancelInvoice(id)`
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const cancelled = await client.cancelInvoice(id);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Allowed only while the invoice is cancellable (`pending`, or `detected` below threshold depending on project config). Raises `TxnodInvoiceNotCancellableError` otherwise. Cancellation is a state transition, not a delete — the invoice record stays in your audit trail and its derivation slot is recycled into the address pool, so the same address may surface on a future invoice from the same project.
|
|
204
|
+
|
|
205
|
+
## Orphan payments
|
|
206
|
+
|
|
207
|
+
A receipt that lands on a derivation address not currently bound to any open invoice is recorded as an *orphan payment*. Typical cause: user paid after `expires_at`, or paid to an address from a previously cancelled invoice.
|
|
208
|
+
|
|
209
|
+
- [`listOrphanPayments`](./reference/client.md#listorphanpayments) — list and filter
|
|
210
|
+
- [`attributeOrphanPayment`](./reference/client.md#attributeorphanpayment) — post-hoc bind an orphan to an `external_id`; the returned invoice reflects the attribution
|
|
211
|
+
|
|
212
|
+
## Related
|
|
213
|
+
|
|
214
|
+
- [`04-webhooks.md`](./04-webhooks.md) — status changes you will observe from the outside
|
|
215
|
+
- [`05-errors.md`](./05-errors.md) — every error `createInvoice` / `getInvoice` / `cancelInvoice` can raise
|
|
216
|
+
- [`reference/types.md`](./reference/types.md) — authoritative type shapes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Rates and quotes"
|
|
3
|
+
description: "Pre-invoice USD→crypto pricing via getRates and quoteAmount. Indicative only — binding rate is captured at createInvoice."
|
|
4
|
+
sdk_version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Rates and quotes
|
|
8
|
+
|
|
9
|
+
Two methods expose current USD→crypto pricing for display before invoice creation. **Both return indicative rates**, not a binding quote. The binding rate is the one captured by `createInvoice` in `invoice.rate_snapshot`. Any drift between a pre-invoice quote and the invoice itself is the partner's responsibility.
|
|
10
|
+
|
|
11
|
+
## `getRates(query)` — list of current rates
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const rates = await client.getRates({
|
|
15
|
+
coins: 'btc,eth,usdt_erc20', // optional CSV; omit to get all enabled coins
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// rates.quoted_at — ISO-8601 UTC timestamp of the quote
|
|
19
|
+
// rates.source — always 'coingecko'
|
|
20
|
+
// rates.indicative_notice — human-readable disclaimer string
|
|
21
|
+
// rates.rates.btc — may be undefined if not enabled/returned
|
|
22
|
+
// rates.rates.btc?.rate — decimal string, USD per 1 BTC, e.g. "62000.00"
|
|
23
|
+
// rates.rates.btc?.rate_is_stale — true if rate is older than the freshness SLO
|
|
24
|
+
// rates.rates.btc?.rate_age_seconds — int seconds since the quote was fetched
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`rates.rates` is keyed by `Coin` (see [`reference/types.md`](./reference/types.md#coin)); each per-coin value may be absent if the coin is not enabled for the project or not returned by the upstream.
|
|
28
|
+
|
|
29
|
+
## `quoteAmount(query)` — USD → per-coin crypto amount
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const quote = await client.quoteAmount({
|
|
33
|
+
amount_usd: 9.99, // required, positive finite number
|
|
34
|
+
coins: 'btc,usdt_trc20', // optional CSV; omit for all enabled coins
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// quote.amount_usd — echoed back
|
|
38
|
+
// quote.quoted_at — ISO-8601 UTC
|
|
39
|
+
// quote.source — always 'coingecko'
|
|
40
|
+
// quote.quotes.btc?.amount_crypto — decimal string, how much BTC for 9.99 USD
|
|
41
|
+
// quote.quotes.btc?.amount_crypto_units — integer string in base units (sats)
|
|
42
|
+
// quote.quotes.btc?.rate — same decimal string as getRates
|
|
43
|
+
// quote.quotes.btc?.rate_is_stale
|
|
44
|
+
// quote.quotes.btc?.rate_age_seconds
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Indicative vs binding
|
|
48
|
+
|
|
49
|
+
| | `getRates` / `quoteAmount` | `createInvoice` |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Source | CoinGecko | CoinGecko |
|
|
52
|
+
| Freshness | `rate_is_stale` / `rate_age_seconds` surfaced | same, captured into `rate_snapshot` |
|
|
53
|
+
| Binding? | No — for display only | **Yes** — invoice amount is fixed |
|
|
54
|
+
| Who owns drift? | Partner | — |
|
|
55
|
+
|
|
56
|
+
If you need a binding quote, call `createInvoice` directly. The invoice embeds `rate_snapshot` with the rate, source, and age at the moment of creation.
|
|
57
|
+
|
|
58
|
+
## `rate_is_stale` — what to do
|
|
59
|
+
|
|
60
|
+
`rate_is_stale: true` means the upstream CoinGecko feed has not refreshed within the project's freshness SLO. You can still create an invoice — the server will reuse the latest cached rate and mark `rate_snapshot.rate_is_stale`. For UX, consider surfacing a "rates may be stale" notice to the payer.
|
|
61
|
+
|
|
62
|
+
## Typical flow
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// 1. Show the user approximate amounts across enabled coins
|
|
66
|
+
const quote = await client.quoteAmount({ amount_usd: 9.99 });
|
|
67
|
+
|
|
68
|
+
// 2. User picks a coin; create the binding invoice
|
|
69
|
+
const invoice = await client.createInvoice({
|
|
70
|
+
amount_usd: 9.99,
|
|
71
|
+
coin: 'usdt_trc20',
|
|
72
|
+
external_id: orderId,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 3. Tell the user the exact `invoice.amount_crypto` (not the earlier indicative)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Related
|
|
79
|
+
|
|
80
|
+
- [`02-invoices.md`](./02-invoices.md#usd-vs-crypto-amount) — how `createInvoice` interprets `amount_usd`
|
|
81
|
+
- [`reference/client.md`](./reference/client.md) — full method signatures
|
|
82
|
+
- [`reference/types.md`](./reference/types.md) — `RatesResponse` / `QuoteResponse` shapes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Webhooks"
|
|
3
|
+
description: "Event types, verifyWebhookSignature, ±300-second window, event_id idempotency, retries, resend."
|
|
4
|
+
sdk_version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Webhooks
|
|
8
|
+
|
|
9
|
+
TxNod sends webhook events to the `callback_url` you set on an invoice. Events are signed with HMAC-SHA256 over the raw request body. The SDK ships [`verifyWebhookSignature`](./reference/client.md#verifywebhooksignature) — the only correct way to accept inbound events.
|
|
10
|
+
|
|
11
|
+
## Event types
|
|
12
|
+
|
|
13
|
+
The `WebhookEvent` type is a discriminated union on `event_type`:
|
|
14
|
+
|
|
15
|
+
| `event_type` | When | Terminal? |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `invoice.detected` | First on-chain receipt seen, below threshold | no |
|
|
18
|
+
| `invoice.paid` | Threshold reached AND exact-amount match | **reorg-reversible** |
|
|
19
|
+
| `invoice.overpaid` | Threshold reached AND strict over-payment | **reorg-reversible** |
|
|
20
|
+
| `invoice.partial` | Receipt below `amount_crypto`, still open | no |
|
|
21
|
+
| `invoice.expired` | `expires_at` passed before `paid` | yes |
|
|
22
|
+
| `invoice.expired_paid_late` | Payment arrived after `expires_at` | yes |
|
|
23
|
+
| `invoice.reverted` | Previously `paid`/`overpaid` dropped by reorg | yes |
|
|
24
|
+
|
|
25
|
+
Every event shares this envelope:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
{
|
|
29
|
+
event_id: string; // ULID — idempotency key, stable across retries
|
|
30
|
+
event_type: '...'; // discriminant above
|
|
31
|
+
created_at: number; // unix seconds
|
|
32
|
+
created_at_iso: string; // ISO-8601 UTC
|
|
33
|
+
project_id: string; // ULID
|
|
34
|
+
attempt: number; // 1-based; increments on retry
|
|
35
|
+
data: Record<string, unknown>; // shape depends on event_type
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Always key your idempotency on `event_id`, not on `(invoice_id, status)`.** The same `event_id` is sent on every delivery attempt. See [`06-idempotency.md`](./06-idempotency.md).
|
|
40
|
+
|
|
41
|
+
## Signature header
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
X-Txnod-Signature: t=<unix_seconds>,v1=<64_lowercase_hex>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `t` — the server's unix timestamp at signing time.
|
|
48
|
+
- `v1` — HMAC-SHA256 hex digest of `${t}.${rawBody}` using `TXNOD_WEBHOOK_SECRET` (which equals `TXNOD_API_SECRET`).
|
|
49
|
+
|
|
50
|
+
## `verifyWebhookSignature(headers, rawBody, secret)`
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import {
|
|
54
|
+
verifyWebhookSignature,
|
|
55
|
+
TxnodHmacError,
|
|
56
|
+
TxnodSignatureFormatError,
|
|
57
|
+
TxnodTimestampError,
|
|
58
|
+
} from '@txnod/sdk';
|
|
59
|
+
|
|
60
|
+
export async function POST(request: Request): Promise<Response> {
|
|
61
|
+
const rawBody = await request.text(); // must be the exact bytes
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const event = verifyWebhookSignature(
|
|
65
|
+
request.headers,
|
|
66
|
+
rawBody,
|
|
67
|
+
process.env.TXNOD_WEBHOOK_SECRET!,
|
|
68
|
+
);
|
|
69
|
+
// event is now narrowed — use event.event_type as the discriminant
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err instanceof TxnodSignatureFormatError) return new Response('bad header', { status: 401 });
|
|
72
|
+
if (err instanceof TxnodHmacError) return new Response('bad sig', { status: 401 });
|
|
73
|
+
if (err instanceof TxnodTimestampError) {
|
|
74
|
+
console.warn('clock skew seconds:', err.skew_seconds);
|
|
75
|
+
return new Response('stale', { status: 401 });
|
|
76
|
+
}
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return Response.json({ ok: true });
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The function is synchronous and throws typed errors on any failure:
|
|
84
|
+
|
|
85
|
+
- `TxnodSignatureFormatError` — header absent or not matching `t=<int>,v1=<hex>`.
|
|
86
|
+
- `TxnodTimestampError` — `|now - t| > 300` seconds. The `skew_seconds` field exposes the signed delta for alerting on drift.
|
|
87
|
+
- `TxnodHmacError` — computed HMAC did not match. Also thrown when the hex is the wrong length.
|
|
88
|
+
- `TxnodWebhookPayloadParseError` — HMAC verified successfully but the body is not valid JSON. Indicates a misbehaving server release. `.cause` is the underlying `SyntaxError`. Treat as 401-class (do not parse, do not act).
|
|
89
|
+
|
|
90
|
+
On success it returns the typed, parsed `WebhookEvent` whose `data` is fully typed under each `event_type` discriminant — see [`reference/types.md`](./reference/types.md#webhookevent).
|
|
91
|
+
|
|
92
|
+
**Headers parameter accepts three shapes**: Web API `Headers`, `Record<string, string>`, or `Record<string, string[]>` — whichever your framework exposes. Matching is case-insensitive.
|
|
93
|
+
|
|
94
|
+
## Raw body is load-bearing
|
|
95
|
+
|
|
96
|
+
HMAC is computed over the exact request bytes. Anything that re-serializes JSON between the wire and your handler breaks the signature.
|
|
97
|
+
|
|
98
|
+
- **Next.js App Router**: `await request.text()` is safe — Next does not pre-parse.
|
|
99
|
+
- **Express**: use `express.raw({ type: 'application/json' })` for the webhook route, **not** `express.json()`. See [`examples/express-webhook-receiver.md`](./examples/express-webhook-receiver.md).
|
|
100
|
+
- **Fastify**: opt into the `content-type-parser` raw body hook.
|
|
101
|
+
- **Any middleware that rewrites bodies (sanitizers, logging wrappers)**: must run after the webhook route, not before.
|
|
102
|
+
|
|
103
|
+
## Retry and delivery guarantees
|
|
104
|
+
|
|
105
|
+
- Deliveries are at-least-once. The same `event_id` can arrive multiple times.
|
|
106
|
+
- Retries are dispatched by the TxNod server (up to the DLQ threshold). Each attempt re-signs with a fresh `t`.
|
|
107
|
+
- Ordering is **not** guaranteed across events for a single invoice. Treat the `status` inside `event.data` as the source of truth, not the arrival order.
|
|
108
|
+
- Return HTTP 2xx to ack. Any non-2xx triggers a retry.
|
|
109
|
+
|
|
110
|
+
## List and resend events
|
|
111
|
+
|
|
112
|
+
- [`listWebhookEvents`](./reference/client.md#listwebhookevents) — paginated list for a project, filterable by `status` (`delivered` / `retrying` / `dlq` / `skipped`), `event_type`, `invoice_id`, `since`.
|
|
113
|
+
- [`resendWebhookEvent(eventId)`](./reference/client.md#resendwebhookevent) — re-enqueues the delivery; produces a new attempt with a fresh `event_id`, and the response carries `original_event_id` for lineage.
|
|
114
|
+
|
|
115
|
+
## Driving webhook events deterministically (sandbox)
|
|
116
|
+
|
|
117
|
+
For tests that need every event type without waiting for chain activity, use the SDK's sandbox surface — see [`05-sandbox.md`](./05-sandbox.md). `client.sandbox.simulateDetect` / `simulatePaid` / `simulateOverpaid` / `simulatePartial` / `simulateExpire` / `simulateLatePayment` / `simulateReorg` / `simulateReconfirm` walk the state machine; the resulting webhooks carry `mode: 'sandbox'` and your verifier code path is unchanged.
|
|
118
|
+
|
|
119
|
+
Use these to recover from a bad deploy that 500'd on real events (pull the DLQ, fix, resend).
|
|
120
|
+
|
|
121
|
+
## Related
|
|
122
|
+
|
|
123
|
+
- [`05-errors.md`](./05-errors.md) — signature errors and the other webhook-side error classes
|
|
124
|
+
- [`06-idempotency.md`](./06-idempotency.md) — why to key on `event_id`
|
|
125
|
+
- [`examples/express-webhook-receiver.md`](./examples/express-webhook-receiver.md) — full Express receiver with raw body
|
|
126
|
+
- [`examples/nextjs-route-handler.md`](./examples/nextjs-route-handler.md) — Next.js 16 App Router handler
|