@duckypay/node
A thin, fully typed wrapper over the REST API: charges, webhook verification, unit conversion — with retries, idempotency, and timeouts built in. ESM + CJS, one runtime dependency.
Install#
npm install @duckypay/nodeimport { DuckyPay } from '@duckypay/node';
// Pass the key directly, or a config object for a custom base URL / fetch.
const duckypay = new DuckyPay(process.env.DUCKYPAY_API_KEY!);Your project API key (dp_test_… / dp_live_…).
Defaults to https://api.duckypay.co.
Extra attempts after a failed request (network error, timeout, 429, 5xx). Default 1; 0 disables.
Per-attempt timeout. Default 30 000 ms; also overridable per request.
Injectable fetch for custom agents or testing.
Create a charge#
duckypay.charges.create() takes the same fields as POST /v1/invoices and returns a typed Charge — a discriminated union on mode, so TypeScript narrows the rail-specific fields for you.
const charge = await duckypay.charges.create({
chainId: 137,
fiat: { currency: 'THB', amount: '350' }, // or amount: '10000000' (smallest unit)
paymentMode: 'both',
reference: order.id,
});
res.redirect(charge.checkoutUrl!);if (charge.mode === 'wallet') {
charge.intent; // SignedPaymentIntent — what the buyer's wallet submits
charge.signature; // DuckyPay's EIP-712 signature over it
} else if (charge.mode === 'transfer') {
charge.depositAddress; // per-invoice deposit address
charge.amount; // exact amount the buyer must send
}
// mode === 'both' carries the fields of both rails.Retrieve & poll#
charges.status() is the lightweight polling read (one indexed lookup server-side); charges.retrieve() returns the full charge — the same data the hosted checkout renders from. Both map to the routes in Charges.
const { status } = await duckypay.charges.status(charge.invoiceId);
// 'pending' | 'confirming' | 'paid' | 'underpaid' | 'expired' | 'failed' | 'refunded'
const full = await duckypay.charges.retrieve(charge.invoiceId);
// full render data: status, token metadata, depositAddress, fiat breakdown, intent…Retries, idempotency, timeouts#
Failed requests (network errors, timeouts, 429, 5xx) retry automatically with exponential backoff and jitter — and every retried create carries an Idempotency-Key, so a retry can never double-charge. A 429’s retryAfterMs is honored. Pass your own key (your order id) to make creation replay-safe even across process crashes.
const duckypay = new DuckyPay({
apiKey: process.env.DUCKYPAY_API_KEY!,
maxRetries: 2, // default 1 — retries network errors, timeouts, 429, 5xx
timeoutMs: 10_000, // default 30_000 (per attempt)
});
// Replay-safe creation: pass your order id as the idempotency key.
await duckypay.charges.create(params, { idempotencyKey: order.id });Verify webhooks#
webhooks.constructEvent() verifies the t=<unix>,v1=<hex> HMAC (constant-time, 5-minute replay window) and parses the event in one call, throwing SignatureVerificationError on anything invalid. webhooks.verify() returns a boolean if you only want the check. Full endpoint recipes: Webhooks.
import { SignatureVerificationError, EVENT_INVOICE_PAID } from '@duckypay/node';
// payload must be the RAW request body string — never re-serialize JSON.
const event = duckypay.webhooks.constructEvent(
rawBody,
req.headers['x-duckypay-signature'] as string,
process.env.DUCKYPAY_WEBHOOK_SECRET!,
);
if (event.type === EVENT_INVOICE_PAID) {
const { invoiceId, reference } = event.data as {
invoiceId: string;
reference: string | null;
};
await fulfillOrder(reference, invoiceId);
}// Unit-test your webhook handler without a real delivery:
const payload = JSON.stringify({ type: 'invoice.paid', data: { invoiceId: '0x…', reference: 'order_1' } });
const header = duckypay.webhooks.generateTestHeader(payload, secret);
const event = duckypay.webhooks.constructEvent(payload, header, secret); // ✓ verifiesAmount helpers#
The API takes amounts as smallest-unit strings. These helpers convert exactly — no floating point anywhere.
import { toBaseUnits, fromBaseUnits } from '@duckypay/node';
toBaseUnits('25', 6); // → '25000000' (25 USDT, 6 decimals)
toBaseUnits('19.99', 6); // → '19990000'
fromBaseUnits('25000000', 6); // → '25'
// Throws DuckyPayError on bad input (negative, too many decimal places, …).Error handling#
Failed requests throw DuckyPayError with the API’s error message and HTTP status. See Errors & envelope for every status and what to do about it.
import { DuckyPayError } from '@duckypay/node';
try {
await duckypay.charges.create({ chainId: 137, amount: '10000000' });
} catch (err) {
if (err instanceof DuckyPayError) {
err.message; // 'too many requests — slow down'
err.status; // 429 (HTTP status, when the API responded)
err.code; // machine-readable code from meta.code, when present
err.retryAfterMs; // on 429s: how long the API asked you to wait
err.requestId; // x-request-id — include it when contacting support
}
}