Webhooks
When an invoice is paid, DuckyPay POSTs a signed event to your endpoint. Verify the signature, fulfil the order, return 200.
The invoice.paid event#
One event type exists today: invoice.paid, sent after the payment is confirmed on-chain. amount is the gross invoice amount in the token’s smallest unit; reference is whatever you passed at creation (or null).
Endpoints are registered per project in the dashboard (or provisioned for you by the CLI scaffold), and each endpoint gets its own signing secret.
POST /webhooks/duckypay HTTP/1.1
content-type: application/json
x-duckypay-event: invoice.paid
x-duckypay-signature: t=1789550000,v1=5f8a3c…e91b{
"type": "invoice.paid",
"data": {
"invoiceId": "0x2b7e…c4a1",
"chainId": 137,
"amount": "25012500",
"merchant": "0xYourWallet…",
"token": "0xc213…58e8F",
"reference": "order_1001"
}
}The signature header#
x-duckypay-signature has the form t=<unix>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Verification must use the raw request bytes — any re-serialization (pretty-printing, key reordering) breaks the HMAC. Reject timestamps older than 300 seconds to close the replay window.
Verifying deliveries#
@duckypay/node does the constant-time comparison and replay check for you; the manual tab shows the equivalent if you’re not on Node.
import express from 'express';
import { DuckyPay, SignatureVerificationError } from '@duckypay/node';
const duckypay = new DuckyPay(process.env.DUCKYPAY_API_KEY!);
const app = express();
// IMPORTANT: the raw body, not parsed JSON — the signature covers the exact bytes.
app.post('/webhooks/duckypay', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = duckypay.webhooks.constructEvent(
req.body.toString('utf8'),
req.headers['x-duckypay-signature'] as string,
process.env.DUCKYPAY_WEBHOOK_SECRET!,
);
if (event.type === 'invoice.paid') {
const { invoiceId, reference } = event.data as { invoiceId: string; reference: string | null };
fulfillOrder(reference, invoiceId); // make this idempotent
}
res.status(200).end();
} catch (err) {
if (err instanceof SignatureVerificationError) return res.status(400).end();
throw err;
}
});Delivery & retries#
| Success criterion | Any 2xx response, within 10 seconds |
| Retries | Up to 5, with exponential backoff |
| Mode scoping | Testnet invoices → test endpoints only; mainnet → live endpoints only |
| Replay tolerance | 300 seconds (SDK default) |
Handler checklist#
- Verify before trusting. Never fulfil from an unverified body — the endpoint URL is guessable; the secret is not.
- Be idempotent. Retries mean you can receive the same event more than once — key your fulfilment on
invoiceId. - Return 200 fast. Queue slow work; a timeout counts as a failure and triggers a retry.
- Missed everything? The chain is the source of truth — poll the status route as a reconciliation fallback; a paid invoice reports
paidregardless of webhook delivery.