Developer integration

From payment event to product access, safely.

Receive signed financial events, automate with the API, inspect every exchange, or give your coding agent the exact contract through llms.txt and MCP.

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.

  1. 1Read the exact raw request body before JSON parsing.
  2. 2Verify the timestamped HMAC-SHA256 signature.
  3. 3Persist event.id as a unique idempotency key.
  4. 4Queue the business action and return any 2xx within 10 seconds.
  5. 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.

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

duckledger-signature.ts
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);
}
app/api/webhooks/duckledger/route.ts
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 payment
payment.confirmedConfirmed by the provider, not yet settled
payment.receivedFunds received — unlock paid access here
payment.overdueDue date passed without payment
payment.refundedFully or partially refunded
payment.canceledCharge canceled
payment.failedCharge failed
subscription.createdSubscription created
subscription.canceledSubscription canceled
asaas.*Every other Asaas family and future event
pingSynthetic event sent by the Test action

Delivery 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 /customers

Create a product customer

POST /plans

Create a recurring billing plan

POST /payments

Create PIX, boleto, or card charges

GET /payments/:id/pix

Read QR and copy-paste PIX data

POST /payment-methods/tokenize

Tokenize a card safely

POST /subscriptions

Start an Asaas subscription from a plan

POST /payments/:id/refund

Refund a charge

POST /payments/:id/cancel

Cancel a charge

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

build
pnpm install
pnpm --filter @duckledger/mcp build
mcp.json
{
  "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.