Skip to main content
Every webhook ListenLand sends includes an X-ListenLand-Signature header signed with HMAC-SHA256 using your endpoint’s secret. Always verify this signature before processing the event — it confirms the request genuinely came from ListenLand and has not been tampered with in transit.

How the signature works

The X-ListenLand-Signature header has the format:
t=<unix-timestamp>,v1=<hex-signature>
For example:
X-ListenLand-Signature: t=1731681120,v1=a3f9c2d8e1b74f605a2c3d9e0f1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
To verify the signature:
  1. Extract the t (Unix timestamp in seconds) and v1 (hex-encoded HMAC) values from the header.
  2. Construct the signed string by concatenating the timestamp, a literal ., and the exact raw request body:
    <timestamp>.<raw-request-body>
    
  3. Compute an HMAC-SHA256 of that string using your webhook secret (whsec_…) as the key.
  4. Compare your computed HMAC to the v1 value — they must match exactly.
  5. Check the timestamp — reject any event where t is more than 5 minutes in the past (or future) to guard against replay attacks.
Always read the raw request body as a string before calling any JSON parser — parsing first will change the exact bytes and invalidate the signature check.

TypeScript verification example

webhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const WEBHOOK_SECRET = process.env.LISTENLAND_WEBHOOK_SECRET!; // whsec_...
const TOLERANCE_SECONDS = 300; // 5 minutes

export function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string
): boolean {
  // Parse `t=<timestamp>,v1=<hmac>` into an object
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("="))
  );
  const timestamp = parts["t"];
  const signature = parts["v1"];

  if (!timestamp || !signature) return false;

  // Reject stale (or future-dated) events to guard against replay attacks
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (ageSeconds > TOLERANCE_SECONDS) return false;

  // Recompute the expected HMAC over "<timestamp>.<rawBody>"
  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // Use a timing-safe comparison to prevent timing oracle attacks
  if (signature.length !== expected.length) return false;
  return timingSafeEqual(
    Buffer.from(signature, "hex"),
    Buffer.from(expected, "hex")
  );
}
Never hardcode your webhook secret in source code. Use an environment variable such as LISTENLAND_WEBHOOK_SECRET.

Using in an Express / Next.js handler

Express

Read the raw body buffer before your JSON middleware touches it, then pass it as a string to verifyWebhookSignature:
express-handler.ts
import express from "express";
import { verifyWebhookSignature } from "./webhook";

const app = express();

// Use express.raw() on the webhook route — do NOT use express.json() here
app.post(
  "/webhooks/listenland",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf-8");
    const signatureHeader = req.headers["x-listenland-signature"] as string;

    if (!verifyWebhookSignature(rawBody, signatureHeader)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(rawBody);

    if (event.event === "conversation.completed") {
      // Handle the completed conversation...
      console.log("Conversation completed:", event.data.conversationId);
    }

    res.status(200).json({ received: true });
  }
);

Next.js App Router (Route Handler)

Next.js Route Handlers expose request.text() to get the raw body before parsing:
app/webhooks/listenland/route.ts
import { verifyWebhookSignature } from "@/lib/webhook";

export async function POST(request: Request) {
  // Read raw body as a string BEFORE any JSON parsing
  const rawBody = await request.text();
  const signatureHeader = request.headers.get("x-listenland-signature") ?? "";

  if (!verifyWebhookSignature(rawBody, signatureHeader)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody);

  if (event.event === "conversation.completed") {
    // Handle the completed conversation...
    console.log("Conversation completed:", event.data.conversationId);
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 });
}

Getting your secret

Your webhook secret is displayed once when you first save a webhook endpoint in the ListenLand dashboard. Copy it immediately and store it securely (e.g. in your cloud secrets manager or as an environment variable). If you lose your secret, the only way to get a new one is to delete and re-create the webhook endpoint in the dashboard. The new endpoint will have a different secret.