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.
Set up the endpoint
Section titled “Set up the endpoint”-
Open the webhooks page in the dashboard.
-
Enter the URL of your endpoint.
-
Copy the signing secret.
-
Send a test delivery.
-
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.
Event types
Section titled “Event types”| 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. |
Payload
Section titled “Payload”Invoice and swap events have this body.
{ "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.
{ "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"}{ "type": "plan.updated", "invoice_id": null, "plan": "pro", "expires_at": 1794592000}Verify the signature
Section titled “Verify the signature”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.
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);}import hashlibimport hmac
def verify(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature or "")package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex")
func verify(rawBody []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature))}Respond and retry
Section titled “Respond and retry”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.
Delivery log
Section titled “Delivery log”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.