Quickstart
In DuckLedger, open Developer → Webhooks → Add endpoint. Use the final public HTTPS URL and save the returned whsec_... secret immediately—it is shown once.
- 1Read the exact raw request body before JSON parsing.
- 2Verify the timestamped HMAC-SHA256 signature.
- 3Persist event.id as a unique idempotency key.
- 4Queue the business action and return any 2xx within 10 seconds.
- 5Send a ping with Test and inspect Developer → Webhook Logs.
Request contract
Each delivery is a self-contained JSON envelope. Money is represented as decimal strings, timestamps are ISO-8601 UTC, and IDs are UUIDs.
POST /webhooks/duckledger HTTP/1.1
Content-Type: application/json
User-Agent: DuckLedger-Webhooks/1.0
X-DuckLedger-Event: payment.received
X-DuckLedger-Delivery: 12465f62-8adf-4c7a-8be9-e84152c029e5
X-DuckLedger-Signature: t=1753208591,v1=6f1c…Verify before parsing
DuckLedger signs <timestamp>.<exact raw body>. Parsing and re-serializing valid JSON changes its bytes and invalidates the signature. Reject timestamps beyond 300 seconds and compare digests in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyDuckLedgerSignature(
rawBody: string,
signatureHeader: string | undefined,
signingSecret: string,
): boolean {
if (!signatureHeader) return false;
const parts = Object.fromEntries(
signatureHeader.split(',').map((segment) => {
const [key, ...value] = segment.trim().split('=');
return [key, value.join('=')];
}),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac('sha256', signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = Buffer.from(parts.v1, 'utf8');
const expectedBytes = Buffer.from(expected, 'utf8');
return received.length === expectedBytes.length
&& timingSafeEqual(received, expectedBytes);
}export async function POST(request: Request) {
const rawBody = await request.text();
const signature =
request.headers.get('x-duckledger-signature') ?? undefined;
if (!verifyDuckLedgerSignature(
rawBody,
signature,
process.env.DUCKLEDGER_WEBHOOK_SECRET!,
)) {
return Response.json(
{ error: 'invalid_signature' },
{ status: 401 },
);
}
const event = JSON.parse(rawBody);
await enqueueIdempotently(event.id, event);
return Response.json({ received: true });
}Event catalog
payment.createdCharge created and awaiting paymentpayment.confirmedConfirmed by the provider, not yet settledpayment.receivedFunds received — unlock paid access herepayment.overdueDue date passed without paymentpayment.refundedFully or partially refundedpayment.canceledCharge canceledpayment.failedCharge failedsubscription.createdSubscription createdsubscription.canceledSubscription canceledasaas.*Every other Asaas family and future eventpingSynthetic event sent by the Test actionDelivery behavior
- At least once: duplicates are expected.
- Any 2xx response marks success.
- Six attempts with exponential backoff from 10 seconds.
- 10-second timeout; acknowledge before slow work.
- Redirects are not followed.
- 15 consecutive failures disable the endpoint.
HTTP API
Production base URL: https://api.duckledger.com/api/v1. Machine clients use a sandbox dlk_test_... or production dlk_live_... bearer key. Keys carry their environment, organization, optional workspace, and permission scope.
POST /customersCreate a product customer
POST /plansCreate a recurring billing plan
POST /paymentsCreate PIX, boleto, or card charges
GET /payments/:id/pixRead QR and copy-paste PIX data
POST /payment-methods/tokenizeTokenize a card safely
POST /subscriptionsStart an Asaas subscription from a plan
POST /payments/:id/refundRefund a charge
POST /payments/:id/cancelCancel a charge
// Success
{ "data": {}, "meta": { "requestId": "..." } }
// Error
{
"error": {
"code": "SOME_ERROR_CODE",
"message": "Human-readable",
"details": {},
"requestId": "..."
}
}MCP and LLM context
The local read-only MCP server gives coding agents canonical resources, event discovery, signature examples, framework handlers, and failure diagnostics. It never receives production credentials and does not call the API.
pnpm install
pnpm --filter @duckledger/mcp build{
"mcpServers": {
"duckledger": {
"command": "node",
"args": [
"/absolute/path/to/duckledger/apps/mcp/dist/index.js"
]
}
}
}Go-live checklist
- A ping succeeds and appears in Webhook Logs.
- Bad, missing, changed-body, and stale signatures are rejected.
- Sending the same event twice produces one business side effect.
- The receiver returns 2xx before slow work and does not redirect.
- Secrets remain server-side and have a documented rotation path.
