> ## Documentation Index
> Fetch the complete documentation index at: https://docs.listen.land/llms.txt
> Use this file to discover all available pages before exploring further.

# How to Verify ListenLand Webhook Signatures Safely

> Validate every incoming webhook by checking the X-ListenLand-Signature header to confirm the payload is genuine and guard against replay attacks.

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.

<Note>
  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.
</Note>

## TypeScript verification example

```typescript webhook.ts theme={null}
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")
  );
}
```

<Warning>
  Never hardcode your webhook secret in source code. Use an environment variable such as `LISTENLAND_WEBHOOK_SECRET`.
</Warning>

## 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`:

```typescript express-handler.ts theme={null}
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:

```typescript app/webhooks/listenland/route.ts theme={null}
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.
