DuckyPayเอกสาร

@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#

terminal
npm install @duckypay/node
server.ts
import { 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!);
apiKeystringrequired

Your project API key (dp_test_… / dp_live_…).

baseUrlstringoptional

Defaults to https://api.duckypay.co.

maxRetriesnumberoptional

Extra attempts after a failed request (network error, timeout, 429, 5xx). Default 1; 0 disables.

timeoutMsnumberoptional

Per-attempt timeout. Default 30 000 ms; also overridable per request.

fetchtypeof fetchoptional

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.

create.ts
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!);
narrowing the result
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.

readback.ts
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.

config.ts
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.

webhook.ts
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);
}
webhook.test.ts
// 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); // ✓ verifies

Amount helpers#

The API takes amounts as smallest-unit strings. These helpers convert exactly — no floating point anywhere.

units.ts
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.

errors.ts
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
  }
}