Skip to main content

Webhooks

If webhookUrl is provided during invoice creation, the API will send a webhook when payment is detected for the first time (pending -> paid).

Delivery Behavior

  • Trigger: New payment detection (status = paid).
  • Method: POST.
  • Content-Type: application/json.
  • Queue type: Local in-memory queue.
  • Retries: Up to 20 attempts with 1 minute delay between attempts.
  • Success criteria: Target endpoint returns any 2xx status.

Webhook Auth Header

Webhook requests include the same authentication type used when the invoice was created:

  1. If invoice was created with API Key:
    • x-depay-api-key: <OWNER_API_KEY>
  2. If invoice was created with Request Signature:
    • x-depay-signature: <ORIGINAL_REQUEST_SIGNATURE>

Webhook Payload Example

{
"ownerAddress": "0x501BEF961A6f40E063efD6048768b0BC35ab1428",
"chainId": 8453,
"invoiceId": "1000456",
"paymentAddress": "0x6c76fD5f0b5F7924A0b6b23223A8D2398fB00E72",
"amount": "12.5",
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"status": "paid",
"paid": true,
"paidAt": 1735600000000,
"requiredRaw": "12500000",
"balanceRaw": "12500000",
"balance": 12.5,
"updatedAt": 1735600001000
}

Receiver Example

Compare the incoming auth header against your own credential, ack with 2xx before doing slow work, and treat delivery as at-least-once — the same pending -> paid event can arrive more than once after a retry.

webhook-route.ts
import crypto from "node:crypto";
import express from "express";

const app = express();
app.use(express.json());

interface InvoicePaidEvent {
ownerAddress: string;
chainId: number;
invoiceId: string;
paymentAddress: string;
amount: string;
tokenAddress: string | null;
status: "paid";
paid: boolean;
paidAt: number;
requiredRaw: string;
balanceRaw: string;
balance: number;
updatedAt: number;
}

function timingSafeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return bufA.length === bufB.length && crypto.timingSafeEqual(bufA, bufB);
}

app.post("/webhooks/invoice", async (req, res) => {
const apiKey = req.header("x-depay-api-key") ?? "";
if (!timingSafeEqual(apiKey, process.env.SODAPOP_API_KEY ?? "")) {
return res.status(401).json({ error: "unauthorized" });
}

const event = req.body as InvoicePaidEvent;

// Ack fast — the queue retries for an hour if we time out.
res.status(200).json({ received: true });

// Delivery is at-least-once, so keep the handler idempotent.
await markOrderPaid(event.invoiceId, {
chainId: event.chainId,
paymentAddress: event.paymentAddress,
amount: event.amount,
paidAt: event.paidAt,
});
});

app.listen(3000);

:::caution paid is not completed The webhook fires on payment detection, not on settlement. If your business logic needs funds withdrawn to the owner wallet, poll GET /integration/invoices/status until status is completed. :::

Important Notes

  • Webhook queue is not persisted in the database (minimal mode).
  • Pending webhook attempts are lost on process restart.
  • The ownerAddress remains required in all invoice API requests regardless of the authentication method. See Auth Model for details.