Skip to content

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.

Terminal window
npm install @ctpeway/sdk

Node.js 20.16 or newer. No runtime dependencies. ES modules and CommonJS are both supported, and type declarations are included.

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.

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 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); // 1500000000000000000n
fromBaseUnits('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'.

// 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.

// 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.

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.

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);

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);

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));
// 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.

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}`);
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),
);
}

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.

  • Every POST is sent with an Idempotency-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-After is honoured up to 60 seconds. Other errors (validation, authentication, not found, conflict) are returned at once.
  • result.lastResponse.idempotentReplayed is true when 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 POST only. PATCH and DELETE are idempotent by themselves; the SDK refuses an idempotencyKey on them rather than send a header that would be ignored.

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.

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.

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());
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 };
});
});

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 });
}

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!,
]);
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': header
  • Other runtimes. constructEventAsync and verifySignatureAsync use Web Crypto and work wherever crypto.subtle exists. They return promises: await them.
  • 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 your switch a default branch: the API can add event types without a new SDK version.

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_attached
const ping = await ctpeway.test.webhooks.ping(); // a test.ping event to your test endpoints
console.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.

  • 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, not payouts: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_url and 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.

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