Node SDK
The official library and command-line tool for the cTpeWay v2 merchant API: invoices with a hosted checkout, reusable deposit addresses, payments, settlements, balances, payouts, refunds and webhooks.
Status: 0.1.0, the first release. Every path, parameter, response and webhook event in this version comes from the API’s own description: 46 operations and 32 webhook events, checked by a contract test. That includes the customer email on invoices and the display precision of quoted amounts.
Install
Section titled “Install”npm install @ctpeway/sdkNode.js 20.16 or newer. No runtime dependencies. ES modules and CommonJS are both supported, and type declarations are included.
Quickstart
Section titled “Quickstart”import { CtpeWay } from '@ctpeway/sdk';
const ctpeway = new CtpeWay(process.env.CTPEWAY_API_KEY!);const invoice = await ctpeway.invoices.create({ price: { currency: 'USD', amount: '49.99' }, options: [{ asset: 'USDT', network: 'bsc' }],});console.log(invoice.checkout_url);In CommonJS:
const { CtpeWay } = require('@ctpeway/sdk');The API key is the only thing the client needs. A key that starts with ctp_test_sk_ works in test
mode, one that starts with ctp_live_sk_ in live mode; there is no separate mode setting.
The SDK is for servers. A secret key can move funds, so it must never reach a browser or a
mobile app. The constructor throws if it is loaded in a browser. Create invoices on your backend
and give the browser only the checkout_url.
Ids are 24-character hex strings (670b8f2e9c1d4a0012ab34c1); event ids start with evt_. The
snippets below use variables such as paymentId for them.
Configuration
Section titled “Configuration”const ctpeway = new CtpeWay(process.env.CTPEWAY_API_KEY!, { baseUrl: 'http://localhost:4000', // local development; default https://api.ctpeway.com timeout: 10_000, // milliseconds per attempt; default 30000 maxRetries: 3, // default 2});console.log(ctpeway.mode); // 'test' or 'live', read from the key| Option | Default | Meaning |
|---|---|---|
baseUrl |
https://api.ctpeway.com |
API origin. Plain http is accepted for localhost, 127.0.0.1, *.localhost and *.test only. |
timeout |
30000 |
Milliseconds allowed for each attempt. |
maxRetries |
2 |
Automatic retries after the first attempt. 0 turns them off. |
initialRetryDelay, maxRetryDelay |
500, 8000 |
Bounds of the exponential backoff, in milliseconds. |
fetch |
platform fetch |
A custom fetch implementation. |
appInfo |
none | { name, version } of your integration, added to the User-Agent. |
Every method also takes per-call options as its last argument:
const controller = new AbortController();const invoice = await ctpeway.invoices.retrieve(invoiceId, { timeout: 5_000, maxRetries: 0, signal: controller.signal,});console.log(invoice.lastResponse.requestId, invoice.lastResponse.attempts);Every result carries a hidden lastResponse (requestId, statusCode, headers,
idempotencyKey, idempotentReplayed, attempts). It does not appear in JSON.stringify or
Object.keys.
Amounts
Section titled “Amounts”Amounts are integer base units of the asset on its network (wei, satoshi, lamports, the
smallest unit of a token), as a decimal string or a bigint. They are never JavaScript numbers: a
float cannot hold 18 decimals. Parameters accept string | bigint, responses contain strings, and
passing a number as an amount throws before any request is sent.
toBaseUnits('1.5', 18); // 1500000000000000000nfromBaseUnits('1500000000000000000', 18); // '1.5'fromBaseUnits(100_000_000n, 6, { minFractionDigits: 2 }); // '100.00'Both conversions are exact. toBaseUnits throws when the text has more decimal places than the
asset has; it does not round. The same asset can have different decimals on different networks
(USDT has 18 on BSC and 6 on Tron), so always take decimals from the asset on that network:
invoice options, payments, payouts, refunds and balances carry it, and ctpeway.assets.list()
lists it. A fiat price is decimal text in US dollars with up to 8 decimals: '49.99'.
Resources
Section titled “Resources”Invoices
Section titled “Invoices”// Priced in USD: the payer chooses an asset on a network, and the amount is quoted then.// Leave `options` out to offer every enabled asset.const invoice = await ctpeway.invoices.create({ price: { currency: 'USD', amount: '49.99' }, options: [ { asset: 'USDT', network: 'bsc' }, { asset: 'USDT', network: 'tron' }, { asset: 'ETH', network: 'ethereum' }, ], order_id: 'order-1042', customer_email: 'ada@example.com', // for your records; never shown on the checkout, never written to success_url: 'https://shop.example/thanks',});console.log(invoice.checkout_url); // send the payer here
// A fixed amount of one asset on one network, in base units.await ctpeway.invoices.create({ options: [{ asset: 'USDT', network: 'tron', amount: toBaseUnits('25', 6) }],});
const again = await ctpeway.invoices.retrieve(invoice.id);if (again.status === 'paid') console.log('paid', again.outcome_flags);
// Once the payer has chosen an option it carries the amount to send. A quoted amount is rounded// up to `display_decimals`: show it with exactly those decimals, and that is what pays the invoice.const chosen = again.options.find((option) => option.id === again.selected_option_id);if (chosen?.amount_due !== undefined) { const amount = fromBaseUnits(chosen.amount_due, chosen.decimals, { minFractionDigits: chosen.display_decimals, }); console.log(`send ${amount} ${chosen.asset} on ${chosen.network} to ${chosen.address}`);}
// Find invoices by order id, description, customer email, invoice number or id.const found = await ctpeway.invoices.list({ q: 'ada@example.com', status: 'paid,underpaid' });console.log(found.data.length);
await ctpeway.invoices.cancel(invoice.id);// Take what was paid as payment in full: an underpayment, or a payment that arrived late.await ctpeway.invoices.accept(invoice.id);const payments = await ctpeway.invoices.listPayments(invoice.id, { status: 'confirmed' });console.log(payments.data.length);An invoice priced in a currency is priced in US dollars. An invoice has three separate facts: its
own status (open, pending, underpaid, paid, expired, canceled, refunded), the
status of each payment, and the status of each payment’s settlement. “Paid” and “held” can both be
true. Fulfil the order on invoice.paid; watch settlements to know when the funds are in your
master wallet.
The amount to send is the displayed one. When the payer chooses an option of an invoice priced
in US dollars, the quoted amount_due is rounded up to display_decimals decimal places, so that
the figure a person reads and types is exactly the amount that pays the invoice. Show it with
fromBaseUnits(amount_due, decimals, { minFractionDigits: display_decimals }), as above, and do
not round it again. On a crypto-denominated invoice display_decimals equals decimals.
customer_email is for your own records. It comes back on the invoice and in its webhook events,
is never shown on the checkout page, and no mail is sent to it. q in list matches it, along
with the order id, the description, the invoice number and the id; status takes one status or
several separated by commas.
Reusable deposit addresses
Section titled “Reusable deposit addresses”// One address for a customer on one network. The answer lists one object per network.const tron = await ctpeway.depositAddresses.create({ name: 'Customer 42', network: 'tron' });for (const address of tron.data) console.log(address.id, address.address, address.network);
// `family: 'evm'`: the same address on every enabled EVM network, each with its own id.// A name that already exists is returned, not duplicated (`created: false`).const evm = await ctpeway.depositAddresses.create({ name: 'Customer 42', family: 'evm' });console.log( evm.created, evm.data.map((address) => address.network),);
// Each address carries what it has received so far.for await (const address of ctpeway.depositAddresses.list({ status: 'active' })) { console.log(address.name, address.network, address.totals.payments, address.totals.usd_value);}
await ctpeway.depositAddresses.update(depositAddressId, { status: 'disabled' });for await (const payment of ctpeway.depositAddresses.listPayments(depositAddressId)) { console.log(payment.amount, `${payment.asset} on ${payment.network}`, payment.status);}A deposit address is one object per network. Payments refer to the id of the network they arrived
on. totals says what the address has received: the number of confirmed payments, their value in
US dollars, and the sum per asset. A disabled address is still watched: payments to it are
recorded.
Payments
Section titled “Payments”for await (const payment of ctpeway.payments.list({ status: 'confirmed', network: 'bsc' })) { console.log( payment.id, fromBaseUnits(payment.amount, payment.decimals), `${payment.asset} on ${payment.network}`, payment.settlement?.status, );}// A payment noticed as a rise of the address balance has no transaction until it is identified.const payment = await ctpeway.payments.retrieve(paymentId);console.log(payment.tx_hash ?? `found by ${payment.detected_by}, transaction not identified yet`);if (payment.status === 'detected') { console.log(`${payment.confirmations} of ${payment.confirmations_required} confirmations`);}A payment is usually found by its transaction. One that is first noticed as a rise of the address
balance (detected_by: 'balance') has no tx_hash and no tx_index until its transaction is
identified; the event payment.transaction_attached announces it, and the payment keeps its id.
Treat tx_hash as optional.
Settlements
Section titled “Settlements”Every confirmed payment gets a settlement. The platform fee is 0.5% of the deposit, always charged in the network’s native coin:
- Native-coin deposit (ETH, BNB, BTC, SOL, TRX): the platform fee and the network fee of the transfer are taken from the deposit when it is released. It is never held.
- Token deposit (USDT, USDC, …): the platform fee and the network fee are paid in native coin
from your master wallet on that network. Until the master wallet holds enough, the deposit stays
in its address and the settlement is
held. Top up the master wallet and it is released.
reason says why a settlement is held or why it failed, as a fixed list of values: for example
insufficient_master_balance, or uneconomic when the network fee of the release would be more
than half of what the deposit is worth.
// A token deposit is held until the master wallet can pay the platform fee and the network fee.const held = await ctpeway.settlements.list({ status: 'held' });for (const settlement of held.data) { console.log( `${settlement.id} needs ${settlement.required_native} ${settlement.native_asset} (base units) on ${settlement.network}`, );}// After topping up the master wallet, ask for a release attempt now instead of waiting.await ctpeway.settlements.release(settlementId);Balances
Section titled “Balances”The master wallet on each network: its address, the balance of each asset, how many settlements
are held, and the native coin needed to release them. Beside amount, each balance has held
(confirmed deposits waiting in their addresses) and pending (deposits being released); neither is
part of amount yet. ctpeway.masterWallets is the same resource under the API’s name.
for await (const wallet of ctpeway.balances.list()) { console.log(wallet.network, wallet.address); for (const balance of wallet.balances) { const amount = fromBaseUnits(balance.amount, balance.decimals); console.log(` ${amount} ${balance.asset} on ${wallet.network}`); // Confirmed deposits that are not in the wallet yet, and so not in `amount`. if (balance.held !== '0') console.log(` held: ${balance.held}, being released: ${balance.pending}`); } if (wallet.held_count > 0) { console.log(` ${wallet.held_count} held; needs ${wallet.required_to_clear} ${wallet.native_asset}`); }}const bsc = await ctpeway.balances.retrieve('bsc');console.log(bsc.required_to_clear);Payouts
Section titled “Payouts”A payout sends an asset on a network from your master wallet to an address. It moves money, and
the API requires an idempotency key for it. The SDK always sends one; give it your own so that a
retry after a crash of your process cannot pay twice (see
Retries and idempotency). A payout goes pending, processing,
included, confirmed, or ends failed with a failure_code.
Permission. A new secret key cannot pay out. It needs the payouts:write scope, which a key
has only if it was granted when the key was created, and it must be restricted by an IP allowlist,
a payout destination allowlist, or both, set under API keys in the dashboard:
- with a destination allowlist, the key pays only the addresses on it;
- with an IP allowlist and no destination allowlist, the key pays any address that is valid on the payout’s network, and only when the request comes from one of those IP addresses.
Otherwise the API answers insufficient_scope, payout_allowlist_required (neither list),
payout_destination_not_allowlisted or ip_not_allowed.
A payout can fail as stale. A payout that is not signed within two hours of being created is
given up instead of being paid late. This happens, for instance, to a payout that was waiting when
cTpeWay’s worker restarted. Nothing was sent: status is failed, failure_code is stale, and
payout.failed is emitted. Create the payout again, with a new idempotency key (the old key would
return the failed payout). While cTpeWay has paused money movement the API answers
payouts_paused and creates nothing; retry later with the same key.
max_network_fee is what was set aside for the network fee and network_fee what the transaction
paid, both in base units of native_asset (native_decimals).
const payout = await ctpeway.payouts.create( { asset: 'USDT', network: 'tron', amount: toBaseUnits('250', 6), destination: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', }, { idempotencyKey: 'payout-2026-10-13-supplier-7' },);console.log(payout.id, payout.status); // 'pending'; payout.* events follow
// Possible until the transaction is signed.await ctpeway.payouts.cancel(payout.id);
const later = await ctpeway.payouts.retrieve(payout.id);if (later.status === 'failed') { // Nothing was sent. `stale`: it was not signed within two hours; create the payout again. console.log(later.failure_code, later.failure_message);} else if (later.network_fee !== undefined) { const fee = fromBaseUnits(later.network_fee, later.native_decimals); console.log(`network fee: ${fee} ${later.native_asset} on ${later.network}`);}
const confirmed = await ctpeway.payouts.list({ status: 'confirmed', limit: 20 });console.log(confirmed.data.map((item) => item.tx_hash));Refunds
Section titled “Refunds”// Pays part or all of a payment back to the payer, as a payout from the master wallet. The// platform fee of the original deposit is not returned.const payment = await ctpeway.payments.retrieve(paymentId);const refund = await ctpeway.refunds.create( { payment_id: payment.id, amount: payment.amount, destination: payerAddress, reason: 'Order canceled' }, { idempotencyKey: 'refund-order-1042' },);console.log(refund.status, refund.payout_id);
const refunds = await ctpeway.refunds.list({ payment_id: payment.id });console.log(refunds.data.length);A refund names the payment, the amount (base units of the payment’s asset), the payer’s address and a reason. The refunds of a payment cannot exceed it.
Permission. A refund is a payout, so the same rules apply: the key needs refunds:write and
payouts:write, neither of which a new key has unless it was granted, and it refunds only to
destinations on its payout allowlist or, with an IP allowlist alone, to any valid address from
those IP addresses. In test mode the refund of a simulated payment is played,
not paid: it is completed at once with simulated: true, and no payout is created.
When its payments have been refunded in full an invoice becomes refunded (refunded_at, event
invoice.refunded); a partial refund sets the flag partially_refunded and emits
invoice.partially_refunded.
Networks, assets and rates
Section titled “Networks, assets and rates”const networks = await ctpeway.networks.list();for (const network of networks.data) { // `confirmations`: a payment needs this many blocks. `finalized` or `safe`: the network decides. console.log(network.slug, network.confirmation_rule, network.confirmations_required);}const assets = await ctpeway.assets.list({ network: 'bsc' });for (const asset of assets.data) console.log(`${asset.asset} on ${asset.network}`, asset.decimals);const rate = await ctpeway.rates.retrieve({ base: 'ETH' });console.log(networks.data.length, `1 ETH = ${rate.rate} ${rate.quote}`);Webhook endpoints and the event log
Section titled “Webhook endpoints and the event log”const endpoint = await ctpeway.webhookEndpoints.create({ url: 'https://shop.example/webhooks/ctpeway', events: ['invoice.paid', 'settlement.held', 'settlement.released'],});saveSecret(endpoint.secret); // shown once: store it, do not log it
await ctpeway.webhookEndpoints.update(endpoint.id, { events: ['*'], description: 'Order events' });const rotated = await ctpeway.webhookEndpoints.rotateSecret(endpoint.id, { overlap_hours: 48 });saveSecret(rotated.secret);
// What was sent to the endpoint, and how each attempt went.for await (const delivery of ctpeway.webhookEndpoints.listDeliveries(endpoint.id)) { console.log(delivery.event_type, delivery.status, delivery.attempts);}await ctpeway.webhookEndpoints.del(endpoint.id);events: ['*'] subscribes an endpoint to every event type. An endpoint whose deliveries run out
of retries becomes failing (deliveries continue); setting status: 'enabled' clears that.
// The event log holds every event, delivered or not. Send the paid invoices again:const paid = await ctpeway.events.list({ type: 'invoice.paid', limit: 50 });for (const event of paid.data) { const { data: deliveries } = await ctpeway.events.redeliver(event.id); console.log( event.id, deliveries.map((delivery) => delivery.status), );}Pagination
Section titled “Pagination”List methods return an object you can await for one page or iterate for everything.
// One page.const page = await ctpeway.invoices.list({ status: 'paid', limit: 50 });console.log(page.data.length, page.has_more, page.next_cursor);if (page.hasNextPage()) await page.nextPage();
// Every item, fetched page by page as the loop advances.for await (const invoice of ctpeway.invoices.list({ status: 'paid' })) { console.log(invoice.id);}
// Into an array, with an upper bound.const firstThousand = await ctpeway.invoices.list().toArray({ limit: 1000 });console.log(firstThousand.length);The request starts when the list is first awaited or iterated. Filters are carried to every page.
Retries and idempotency
Section titled “Retries and idempotency”- Every
POSTis sent with anIdempotency-Key. The SDK generates one per call (a UUID) and reuses it on every automatic retry of that call, so a request that reached the API before the connection dropped is answered from the stored result instead of being executed twice. - Retried automatically, with exponential backoff and jitter: no response at all, a timeout,
HTTP 408, 429, 500, 502, 503 and 504, and 409
idempotency_request_in_progress(the first attempt with that key is still running).Retry-Afteris honoured up to 60 seconds. Other errors (validation, authentication, not found, conflict) are returned at once. result.lastResponse.idempotentReplayedistruewhen the API answered from the stored result of an earlier request with the same key, which means the operation did not run a second time.- When the retries run out on a write, the outcome is unknown: the request may have been processed.
The error carries the
idempotencyKey; calling again with that key returns the original result. - An automatic key protects one call. To be safe across a crash or a redeploy of your own process,
pass a key derived from your data, as in the payout example:
{ idempotencyKey: 'payout-…' }. The same key with a different request is rejected (CtpeWayIdempotencyError,idempotency_key_reused). Keys are kept by the API for 24 hours, per account: a key first used by an API key with more permissions is not replayed to one with fewer (idempotency_key_not_yours). - The API honours the key on
POSTonly.PATCHandDELETEare idempotent by themselves; the SDK refuses anidempotencyKeyon them rather than send a header that would be ignored.
Errors
Section titled “Errors”Every failure is a CtpeWayError with a type, a stable code, a message, and when the API
answered, statusCode, requestId, param and docUrl.
| Class | type |
When |
|---|---|---|
CtpeWayInvalidRequestError |
invalid_request |
400. A parameter is missing or wrong; param names it. |
CtpeWayAuthenticationError |
authentication |
401. The key is missing, wrong, revoked or expired. |
CtpeWayPermissionError |
permission |
403. The key lacks the scope, or its IP allowlist rejects the caller. |
CtpeWayNotFoundError |
not_found |
404. |
CtpeWayConflictError |
conflict |
409. The resource is not in a state that allows the action. |
CtpeWayIdempotencyError |
idempotency |
422. The idempotency key was used with another request. |
CtpeWayRateLimitError |
rate_limited |
429 after the retries; retryAfter is in seconds. |
CtpeWayApiError |
api_error |
5xx, or a response that is not the API’s JSON. |
CtpeWayConnectionError |
connection |
No response. CtpeWayTimeoutError is the timeout case. |
CtpeWaySignatureVerificationError |
signature_verification |
A webhook failed verification. |
CtpeWayError |
sdk |
A mistake caught before any request: a bad key, a number passed as an amount. |
try { await ctpeway.payouts.create( { asset: 'USDT', network: 'bsc', amount: toBaseUnits('250', 18), destination: payerAddress }, { idempotencyKey: 'payout-supplier-7' }, );} catch (error) { if (error instanceof CtpeWayConnectionError) { // No answer: the payout may or may not exist. Calling again with the same idempotencyKey // returns the original result instead of paying twice. console.error(error.idempotencyKey); } else if (error instanceof CtpeWayRateLimitError) { console.error(`slow down, retry in ${error.retryAfter} s`); } else if (error instanceof CtpeWayInvalidRequestError && error.param) { console.error(`${error.param}: ${error.message}`); } else if (error instanceof CtpeWayError) { switch (error.code) { case 'payout_destination_invalid': case 'payout_destination_own_address': console.error('check the destination address'); break; case 'insufficient_scope': console.error('this API key was not granted payouts:write'); break; case 'payout_allowlist_required': console.error('restrict this API key by IP address or by destination in the dashboard'); break; case 'payout_destination_not_allowlisted': console.error('add the destination to the payout allowlist of this API key'); break; default: console.error(error.type, error.code, error.requestId); // quote requestId to support } } else { throw error; }}Branch on error.code or error.type, not on the message. The codes the API can send are typed
from its description, so an editor suggests them; ErrorCodeOf<'createPayout'> is the list for one
operation. CtpeWayError.isCtpeWayError(error) works where instanceof cannot (two copies of the
package loaded). Errors never contain the API key; error.toJSON() is safe to log.
Two codes mean “not now” and not “wrong request”: address_derivation_unavailable and
address_derivation_unverified are answered when no address can be handed out at the moment
(creating a deposit address, simulating a payment). Try again shortly.
Webhooks
Section titled “Webhooks”Each delivery is a JSON envelope { id, type, api_version, created, livemode, data: { object } }
with the header
Ctpeway-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]where <hex> is the lowercase HMAC-SHA256 of "<t>.<raw body>" with the endpoint’s signing
secret (the whole whsec_… string) as the key.
constructEvent(rawBody, signatureHeader, secret) checks the signature with a constant-time
comparison, rejects deliveries signed more than 300 seconds away from the current time (the replay
window; set { tolerance: seconds } to change it), and returns the typed event. It needs no API
key: CtpeWay.webhooks.constructEvent(...).
Verify the raw body. The signature covers the exact bytes that were sent. If a JSON parser has already turned the body into an object, verification cannot work, and re-serializing does not give the same bytes. Each example below shows how to get the raw body in that framework.
Answer 2xx quickly and do slow work afterwards. Deliveries are retried and can arrive more than
once or out of order: make your handler idempotent, keyed on event.id.
Express
Section titled “Express”import { CtpeWay, CtpeWaySignatureVerificationError, type WebhookEvent } from '@ctpeway/sdk';import express from 'express';
export const app = express();
// The signature covers the raw bytes of the body. Give this route express.raw(), and register it// before any app-wide express.json(), which would replace the bytes with a parsed object.app.post('/webhooks/ctpeway', express.raw({ type: 'application/json' }), (req, res) => { let event: WebhookEvent; try { event = CtpeWay.webhooks.constructEvent( req.body, req.headers['ctpeway-signature'], process.env.CTPEWAY_WEBHOOK_SECRET!, ); } catch (error) { if (error instanceof CtpeWaySignatureVerificationError) { res.status(400).send(error.message); return; } throw error; }
// Deliveries can repeat: make the handling idempotent, keyed on event.id. switch (event.type) { case 'invoice.paid': console.log(`invoice ${event.data.object.id} is paid`); break; case 'settlement.held': console.log(`top up the master wallet on ${event.data.object.network}`); break; default: break; } res.json({ received: true });});
app.use(express.json());Fastify
Section titled “Fastify”import { CtpeWay, CtpeWaySignatureVerificationError, type WebhookEvent } from '@ctpeway/sdk';import Fastify from 'fastify';
export const app = Fastify();
// The signature covers the raw bytes of the body. Inside this plugin only, JSON bodies stay a// Buffer; every other route of the application keeps Fastify's normal JSON parsing.app.register(async (webhooks) => { webhooks.addContentTypeParser('application/json', { parseAs: 'buffer' }, (_request, body, done) => { done(null, body); });
webhooks.post('/webhooks/ctpeway', async (request, reply) => { let event: WebhookEvent; try { event = CtpeWay.webhooks.constructEvent( request.body as Buffer, request.headers['ctpeway-signature'], process.env.CTPEWAY_WEBHOOK_SECRET!, ); } catch (error) { if (error instanceof CtpeWaySignatureVerificationError) { return reply.code(400).send(error.message); } throw error; }
// Deliveries can repeat: make the handling idempotent, keyed on event.id. if (event.type === 'invoice.paid') console.log(`invoice ${event.data.object.id} is paid`); return { received: true }; });});Next.js route handler
Section titled “Next.js route handler”app/api/webhooks/ctpeway/route.ts:
import { CtpeWay, CtpeWaySignatureVerificationError, type WebhookEvent } from '@ctpeway/sdk';
// constructEvent uses Node's crypto. On the Edge runtime use `await constructEventAsync(...)`.export const runtime = 'nodejs';
export async function POST(request: Request): Promise<Response> { // request.text() is the raw body. Do not call request.json(): the signature covers the bytes. const body = await request.text();
let event: WebhookEvent; try { event = CtpeWay.webhooks.constructEvent( body, request.headers.get('ctpeway-signature'), process.env.CTPEWAY_WEBHOOK_SECRET!, ); } catch (error) { if (error instanceof CtpeWaySignatureVerificationError) { return new Response(error.message, { status: 400 }); } throw error; }
// Deliveries can repeat: make the handling idempotent, keyed on event.id. if (event.type === 'invoice.paid') console.log(`invoice ${event.data.object.id} is paid`); return Response.json({ received: true });}Rotating a secret
Section titled “Rotating a secret”After rotateSecret, deliveries carry two v1 signatures, one for the new secret and one for the
previous one, for the overlap window (24 hours unless you pass overlap_hours). A server that
still has the old secret keeps verifying, so you can switch at any moment in that window:
constructEvent accepts a delivery when any signature in the header matches. If you would rather
try both secrets on your side, pass an array:
const event = CtpeWay.webhooks.constructEvent(rawBody, signature, [ process.env.CTPEWAY_WEBHOOK_SECRET!, process.env.CTPEWAY_WEBHOOK_SECRET_PREVIOUS!,]);Testing your handler
Section titled “Testing your handler”const payload = JSON.stringify({ id: 'evt_test', type: 'test.ping', api_version: 'v1', created: Math.floor(Date.now() / 1000), livemode: false, data: { object: { message: 'ping', sent_at: new Date().toISOString() } },});const header = CtpeWay.webhooks.generateTestHeader({ payload, secret: 'whsec_test' });// POST payload to your endpoint with the header 'Ctpeway-Signature': headerOther cases
Section titled “Other cases”- Other runtimes.
constructEventAsyncandverifySignatureAsyncuse Web Crypto and work wherevercrypto.subtleexists. They return promises:awaitthem. - Signature only.
CtpeWay.webhooks.verifySignature(rawBody, header, secret)checks the signature and the replay window without parsing the body. - Event types are listed in
WEBHOOK_EVENT_TYPES. Give yourswitchadefaultbranch: the API can add event types without a new SDK version.
Test mode
Section titled “Test mode”A test key (ctp_test_sk_…) works on public testnets with separate data, separate webhook
endpoints and separate master wallets. Nothing in the code changes between test and live except
the key. Test mode runs the real settlement engine, so platform fees and network fees are charged
in testnet coins and you can see a settlement go from pending to held to released.
The simulators create payments without a testnet or a faucet (tx_hash: sim_…,
simulated: true). They exist in test mode only; with a live key the SDK refuses them before
sending anything.
const invoice = await ctpeway.invoices.create({ options: [{ asset: 'USDT', network: 'bsc-testnet', amount: toBaseUnits('10', 18) }],});
// A payment that is seen but not confirmed yet, as your integration first meets it...const detected = await ctpeway.test.invoices.simulatePayment(invoice.id, { stage: 'detected' });// ...gains a confirmation, as the checkout page counts them...await ctpeway.test.payments.addConfirmation(detected.id);// ...then is confirmed, with its settlement held, to see what your integration does with that.const payment = await ctpeway.test.payments.confirm(detected.id, { settlement: 'held' });console.log(payment.tx_hash, payment.simulated, payment.settlement?.status);
// A deposit noticed as a balance change has no transaction until it is identified.const inferred = await ctpeway.test.depositAddresses.simulatePayment(depositAddressId, { asset: 'USDT', inferred: true,});await ctpeway.test.payments.attachTransaction(inferred.id); // emits payment.transaction_attachedconst ping = await ctpeway.test.webhooks.ping(); // a test.ping event to your test endpointsconsole.log(ping.event.id, ping.deliveries.length);Outcomes: paid, underpaid, overpaid, late, reorg, dropped. The payment goes to the
option you name with option_id, or with asset and network; otherwise to the selected option,
or to the only one the invoice has. A payment simulated with stage: 'detected' stays unconfirmed
until test.payments.confirm, or until test.payments.addConfirmation has counted it up to the
network’s confirmations_required. inferred: true simulates a payment noticed as a balance
change, without a transaction, and test.payments.attachTransaction plays its transaction being
found. Events from test mode have livemode: false.
Keeping the key safe
Section titled “Keeping the key safe”- Keep the key in an environment variable or a secret manager, not in the repository.
- A secret key is not bound to a domain: a server can send any header, so registering a domain
cannot stop a stolen secret key from being used elsewhere. What limits a key is its scopes, its
IP allowlist, payout allowlists and limits, and rotation. Give each key the smallest scope that
works (a key that only creates invoices needs
invoices:write, notpayouts:write). - A new secret key cannot move funds out. Payouts and refunds need scopes that are granted only on request when the key is created, and the key must be restricted by the IP addresses of your servers, by a payout allowlist of destination addresses, or by both. Use a separate key for payouts, and keep its lists short.
- Publishable keys (
ctp_…_pk_…) are for the browser and can read reference data only (networks, assets, rates). This SDK does not accept them: it is for secret keys, on a server. - Verified domains decide where webhooks and checkout redirects may go:
success_url,cancel_urland webhook URLs must be on a domain you verified (localhost is accepted in test mode). - The client holds the key in a private field. Printing the client, a resource, a result or an error never shows it.
Provisional endpoints
Section titled “Provisional endpoints”None. published: defined by the API description; paths, methods and types come from it.
provisional would mark a resource the SDK offers before the API describes it; a test fails when
this table and the description disagree. npm run contract:status prints every operation.
| Resource | Methods | Paths | Status |
|---|---|---|---|
invoices |
create retrieve list cancel accept acceptLatePayment listPayments |
/v1/invoices |
published |
depositAddresses |
create retrieve list update listPayments |
/v1/deposit_addresses |
published |
payments |
retrieve list |
/v1/payments |
published |
settlements |
retrieve list release |
/v1/settlements |
published |
balances (masterWallets) |
list retrieve |
/v1/master_wallets |
published |
payouts |
create retrieve list cancel |
/v1/payouts |
published |
refunds |
create retrieve list |
/v1/refunds |
published |
networks |
list |
/v1/networks |
published |
assets |
list |
/v1/assets |
published |
rates |
retrieve |
/v1/rates |
published |
webhookEndpoints |
create retrieve list update del rotateSecret listDeliveries |
/v1/webhook_endpoints |
published |
events |
retrieve list listDeliveries redeliver |
/v1/events |
published |
test.payments |
confirm addConfirmation attachTransaction |
/v1/test/payments |
published |
test.webhooks |
ping |
/v1/test/webhooks |
published |
test.invoices |
simulatePayment |
/v1/test/invoices |
published |
test.depositAddresses |
simulatePayment |
/v1/test/deposit_addresses |
published |

