Webhooks/Receiving webhooks

Receiving webhooks

Your receiver needs to accept a POST, verify its signature against the raw body, handle the event once, and return a 2xx response. Start by adding an endpoint in the dashboard.

Verify the signature

Calculate HMAC-SHA256 using the entire signing secret as a UTF-8 string and the exact bytes received in the request body. Compare the resulting digest with the hex value in X-Nodana-Signature using a constant-time comparison.

Do not parse and re-serialize the JSON before checking the signature: whitespace and key order affect the bytes being signed. If you use a framework with JSON middleware, configure this route to preserve the raw body.

Node.js receiver

Save this as receiver.mjs. It uses Node.js built-in modules and requires no packages.

import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = process.env.NODANA_WEBHOOK_SECRET;
if (!secret) throw new Error("Set NODANA_WEBHOOK_SECRET before starting");

// Demo only: this set is lost on restart and grows with each delivery.
// In production, use a durable inbox with a unique delivery ID.
const acceptedIds = new Set();

function validSignature(body, signature) {
  if (typeof signature !== "string" || !/^[a-f0-9]{64}$/i.test(signature)) {
    return false;
  }
  const expected = createHmac("sha256", secret).update(body).digest();
  return timingSafeEqual(expected, Buffer.from(signature, "hex"));
}

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/webhooks/nodana") {
    res.writeHead(404).end();
    return;
  }

  try {
    const chunks = [];
    let size = 0;
    for await (const chunk of req) {
      size += chunk.length;
      if (size > 64 * 1024) {
        res.writeHead(413).end();
        return;
      }
      chunks.push(chunk);
    }
    const body = Buffer.concat(chunks);
    if (!validSignature(body, req.headers["x-nodana-signature"])) {
      res.writeHead(401).end();
      return;
    }

    const eventId = req.headers["x-nodana-event-id"];
    if (typeof eventId !== "string" || !eventId) {
      res.writeHead(400).end();
      return;
    }

    let event;
    try {
      event = JSON.parse(body.toString("utf8"));
    } catch {
      res.writeHead(400).end();
      return;
    }
    if (!event || typeof event.type !== "string") {
      res.writeHead(400).end();
      return;
    }

    if (acceptedIds.has(eventId)) {
      res.writeHead(204).end();
      return;
    }

    if (event.type === "payment_received") {
      // Demonstrates receipt only; this does not fulfil an order.
      console.log("Payment received", {
        eventId,
        paymentHash: event.paymentHash,
        amountSat: event.amountSat,
      });
    }
    acceptedIds.add(eventId);
    res.writeHead(204).end();
  } catch (error) {
    console.error("Webhook receiver failed", error);
    if (!res.headersSent) res.writeHead(500).end();
  }
}).listen(3000, () => console.log("Webhook receiver listening on port 3000"));

Start it with your endpoint's signing secret:

export NODANA_WEBHOOK_SECRET="<your-whsec-signing-secret>"
node receiver.mjs

The local server listens over HTTP. Deploy it behind an HTTPS reverse proxy or use a public HTTPS tunnel and register its /webhooks/nodana URL in the dashboard. A localhost URL cannot receive deliveries from Nodana.

For production, replace the in-memory set and logging with a durable inbox or queue. Verify the signature, then atomically store the event with a unique delivery ID before returning 2xx. If storage fails, return 5xx so Nodana retries. Process accepted events in a worker and use a unique node/payment-hash record to prevent duplicate payment fulfilment. Acknowledge repeated accepted deliveries with 2xx. Avoid doing slow work in the request handler, since Nodana times out after 10 seconds.

Test locally

In a second terminal, set the same NODANA_WEBHOOK_SECRET and save this script as send-test.mjs. It signs a sample payload and sends it to the local receiver. This checks your receiver only; it does not create a real payment or a Nodana delivery record.

import { createHmac } from "node:crypto";

const secret = process.env.NODANA_WEBHOOK_SECRET;
if (!secret) throw new Error("Set NODANA_WEBHOOK_SECRET before testing");

const body = JSON.stringify({
  type: "payment_received",
  amountSat: 1000,
  paymentHash: "0123456789abcdef".repeat(4),
});
const signature = createHmac("sha256", secret).update(body).digest("hex");

const response = await fetch("http://localhost:3000/webhooks/nodana", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Nodana-Signature": signature,
    "X-Nodana-Event-Id": "local-test-1",
    "X-Nodana-Event-Type": "payment_received",
  },
  body,
});
console.log("Receiver status:", response.status);
export NODANA_WEBHOOK_SECRET="<your-whsec-signing-secret>"
node send-test.mjs

Expect 204 and one payment log in the receiver terminal. Run the script again with the same event ID: it should still return 204 without logging a second payment. Change the test secret to an incorrect value: the receiver should return 401.

Test a real delivery

  1. Deploy the receiver at your registered HTTPS URL with the correct signing secret.
  2. Create an invoice on that node and pay it. See the Phoenixd API reference for invoice operations.
  3. Check your server logs and the node's Webhooks delivery history. A successful request should show delivered with your 2xx status.

If delivery fails, inspect the recorded response status and error. Check that your destination is publicly reachable over HTTPS, has a valid TLS certificate, accepts POST requests at the exact path, and does not redirect or require browser authentication. A 401 from the example receiver usually means the secret is incorrect or the request body was changed before signature verification.

On this page