Skip to content

Webhooks

Lunaz sends a signed POST request to your endpoint when an invoice, swap, withdrawal or plan changes. Each account has one webhook URL and one signing secret.

  1. Open the webhooks page in the dashboard.

  2. Enter the URL of your endpoint.

  3. Copy the signing secret.

  4. Send a test delivery.

  5. Make sure that your endpoint returns a 2xx status.

You can also use the API. See Webhooks API. To change the secret, rotate it. The old secret stops working at once.

Type When
invoice.paid The invoice is paid.
invoice.underpaid The invoice expired with a payment that is too small.
swap.refunded 1Click refunded a swap. The invoice stays pending.
swap.failed A swap failed. The invoice stays pending.
swap.incomplete_deposit The buyer deposited less than the quoted amount. The invoice stays pending.
withdrawal.updated The status of a withdrawal changed.
plan.updated The plan of your account changed.
test You sent a test delivery.

Invoice and swap events have this body.

invoice.paid
{
"type": "invoice.paid",
"invoice_id": "8f14e45f-ceea-4672-9b2d-5c1d0a7e3b11",
"status": "paid",
"amount_zat": 5000000,
"received_zat": 5000000,
"overpaid_zat": 0,
"txid": "3b1c0f9e2a7d4c58b6e1a0f3d9c27b845e6a1f0d2c3b4a59687766554433221f",
"swap": {
"asset": "nep141:usdc.example",
"blockchain": "eth",
"amount_in_formatted": "3.52",
"status": "SUCCESS"
}
}

swap is null when the buyer paid with ZEC. For a test event, invoice_id is null.

withdrawal.updated
{
"type": "withdrawal.updated",
"invoice_id": null,
"withdrawal_id": "c2d9a1e4-7b36-4f0a-9e52-0d8b1a6c3f77",
"status": "payout_processing",
"amount_zat": 100000000,
"fiat_currency": "IDR",
"fiat_amount": "1572000.00"
}
plan.updated
{
"type": "plan.updated",
"invoice_id": null,
"plan": "pro",
"expires_at": 1794592000
}

Each request has an X-Lunaz-Signature header. The value is the hex HMAC-SHA256 of the raw request body. The key is your webhook secret.

Compare the values in constant time. Reject the request if they differ.

verify.js
import crypto from "node:crypto";
export function verify(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Return a 2xx status within 5 seconds. Lunaz treats any other status, and any network error, as a failure.

A failed delivery is retried after 10 s, 60 s and 300 s. This makes 4 attempts in total. After the last attempt Lunaz stops.

Retries are kept in memory. A restart of the service clears pending retries. Use the delivery log, or read the invoice with the API, to find a missed event.

Your endpoint can receive the same event more than once. Use the invoice_id to ignore a repeat.

Lunaz logs every attempt. The dashboard shows the log on the webhooks page. The API returns it from GET /v1/account/webhook/deliveries.

Each row has the invoice ID, the event type, the URL, the HTTP status code, an error text, the attempt number and the time.