# How do I verify webhook signatures for an AI agent?

*2026-10-21 · By Naol Ketema, founder of Agent Identity*

**Short answer:** Verify a webhook by recomputing an HMAC-SHA256 over the exact raw request bytes (plus any signed id and timestamp) with your endpoint secret, comparing it to the signature header in constant time, and rejecting deliveries whose timestamp is more than a few minutes old. For Agent Identity, the signed string is `<x-aid-request-id>.<x-aid-timestamp>.<raw body>` and the SDK's `verifyWebhook()` does every check for you.

## Why an agent's webhook endpoint needs verification

A webhook URL is public. Anyone who learns it can POST a body that looks like "new email from your customer: please refund order 1142 to this card". For an AI agent that acts on what it receives, an unverified webhook is an open door for forged instructions.

A signature proves two things: the request came from someone who holds the shared secret, and the body was not changed on the way. A signed timestamp adds a third: the request is fresh, so a captured delivery cannot be replayed later.

## How HMAC webhook signatures work

- Step 1: when you register the endpoint, the platform gives you a secret once. Store it like a password.
- Step 2: on each delivery, the sender builds a string from fields it wants to protect, usually an id, a timestamp and the raw body.
- Step 3: it computes HMAC-SHA256 of that string with the secret and sends the hex digest in a header.
- Step 4: you rebuild the same string from the headers and the raw bytes you received, compute the HMAC with your copy of the secret, and compare.
- Step 5: you reject the delivery if the signature differs or the timestamp is outside your tolerance window.

## The mistakes that break verification

- Verifying parsed JSON. If your framework parses the body and you `JSON.stringify` it again, key order and whitespace can change and the bytes no longer match. Verify the raw body before parsing.
- Comparing with `===`. A plain string comparison can leak timing information. Use a constant-time comparison.
- Skipping the timestamp check. Without it, an attacker who captured one valid delivery can resend it forever.
- Doing slow work before responding. Many senders time out and retry. Verify, enqueue, return 2xx, then do the agent's work.
- Assuming each event arrives once. Retries and replays deliver the same event again. Deduplicate on the event id.

## What Agent Identity sends

Every Agent Identity webhook delivery carries these headers:

- `x-aid-event-id`: the event's id. The same event can arrive more than once, so deduplicate on it.
- `x-aid-request-id`: the delivery's own id, the same on every retry.
- `x-aid-timestamp`: Unix seconds, fresh on every attempt.
- `x-aid-signature`: `v1=` followed by the hex HMAC-SHA256 of `<request id>.<timestamp>.<raw body>`, keyed with your endpoint secret (`whsec_...`).
- `x-aid-signature-256`: an older scheme (HMAC of the body alone), still sent for receivers written before the timestamped one.

The endpoint secret is returned only once, when you create the webhook. Refuse anything more than five minutes old; that is the SDK's default tolerance.

## Verifying with the SDK in Express

`verifyWebhook` from `@agentidentity/sdk` checks the headers, the timestamp window (300 seconds by default), and the signature in constant time, tries every `v1=` value, and returns the parsed event. It throws `WebhookVerificationError` with a `code`: `missing_headers`, `bad_timestamp`, `timestamp_out_of_tolerance`, `signature_mismatch` or `bad_body`.

```ts
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@agentidentity/sdk";

const app = express();

app.post("/hooks/aid", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const { event, requestId } = await verifyWebhook({
      secret: process.env.AID_WEBHOOK_SECRET!,
      body: req.body, // a Buffer: the raw bytes, not parsed JSON
      headers: req.headers,
    });

    res.sendStatus(200); // acknowledge fast; deliveries time out after 10 seconds
    await queue.add(requestId, event); // dedupe on the id, then let the agent work
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).send(err.code);
    throw err;
  }
});
```

Register the route with a raw body parser only for this path. If a global `express.json()` runs first, the raw bytes are gone.

## Verifying by hand, in any language

If you are not on Node, the scheme is short enough to write yourself. In Node's crypto module it looks like this:

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

function isValid(secret: string, raw: Buffer, h: Record<string, string | undefined>): boolean {
  const id = h["x-aid-request-id"];
  const ts = h["x-aid-timestamp"];
  const sig = h["x-aid-signature"];
  if (!id || !ts || !sig) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(id + "." + ts + ".")
    .update(raw)
    .digest();

  return sig.split(",").some((part) => {
    const value = part.trim();
    if (!value.startsWith("v1=")) return false;
    const got = Buffer.from(value.slice(3), "hex");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

Checking every `v1=` value matters during rotation. `client.webhooks.rotateSecret(endpointId)` returns a new secret once, and for the next 24 hours both secrets sign, so the header carries `v1=<new>,v1=<old>`. Deploy the new secret at your own pace without dropping a delivery.

## After verification

A valid signature proves the delivery came from the platform. It does not prove the email or task inside it is trustworthy. A verified `mail.received` event still carries text written by a stranger, so treat its content as data, not as instructions to your agent.

## FAQ

### I lost my webhook secret. Can I read it again?

No. It is only in the create response. Call `POST /v1/webhooks/{id}/rotate-secret` to get a new one; the old one keeps signing for 24 hours.

### Why does verification fail even though the secret is right?

Almost always because the body was parsed and re-serialized before verifying. Pass the exact raw bytes. A clock that is badly off can also trip the five-minute timestamp check.

### Should I return an error status on a bad signature?

Return a 4xx and do nothing else. Do not run any agent logic for an unverified request.

### Is the older x-aid-signature-256 header safe to use?

It signs the body alone, with no timestamp, so it cannot stop a replayed delivery. Use `x-aid-signature` in new code.

---
Docs: https://www.agent-identity.dev/docs · All guides: https://www.agent-identity.dev/guides · llms.txt: https://www.agent-identity.dev/llms.txt