---
title: "Verifying Webhook Signatures"
description: "Verify that webhook deliveries are authentic and untampered, in any framework"
---

> **For AI agents:** the complete documentation index is at [llms.txt](/llms.txt). Append `.md` to any page URL for its markdown version.

Every webhook delivery is signed with the endpoint's signing secret using HMAC-SHA256. You must
verify the signature before processing any event to prevent spoofed requests.

The TypeScript SDK ships two flavors of the verifier so you don't have to implement the signature
scheme yourself:

- `parseWebhookEvent` (sync, uses `node:crypto`) — for Node.js servers (Express, Fastify, plain
  `http`, AWS Lambda on Node runtime, etc.)
- `parseWebhookEventAsync` (async, uses WebCrypto) — for any Fetch-API runtime: Next.js app router,
  Cloudflare Workers, Vercel Edge, Deno, Bun

Both throw `WebhookVerificationError` if the signature is invalid, the timestamp is stale (>5 min
default), or required headers are missing. Both return a typed `WebhookEvent` on success.

<Info>
  **Why two functions?** Most edge runtimes don't ship Node's `crypto` module. WebCrypto is
  universally available, but its API is async. Pick the one that matches your runtime; the
  signing scheme is identical.
</Info>

## The signature scheme

If you're implementing in a non-TypeScript language, you'll need the raw scheme. The HMAC is
computed over:

```
{webhook_id}.{timestamp}.{body}
```

Where:
- `webhook_id` is the `Webhook-Id` header
- `timestamp` is the `Webhook-Timestamp` header (Unix seconds)
- `body` is the raw request body (not parsed JSON)

The result is base64-encoded and prefixed with `v1,`. The `Webhook-Signature` header may contain
multiple space-separated signatures during key rotation — accept any match.

<Warning>
  Always read the raw body for signature verification. Parsing JSON first and re-stringifying
  changes whitespace and breaks the HMAC.
</Warning>

## Framework recipes

Below: the minimum complete handler for each common partner runtime. Copy, paste, route your
events.

### Next.js (app router)

```typescript
// app/api/webhooks/agg/route.ts
import { parseWebhookEventAsync, WebhookVerificationError } from "@agg-build/sdk/server";

export async function POST(req: Request) {
  try {
    const event = await parseWebhookEventAsync(
      await req.text(),
      Object.fromEntries(req.headers),
      process.env.AGG_WEBHOOK_SECRET!,
    );

    switch (event.type) {
      case "trades.placed":
        await handleTrade(event.data);
        break;
      case "markets.resolved":
        await handleResolution(event.data);
        break;
    }
    return Response.json({ received: true });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return Response.json({ error: err.message }, { status: 401 });
    }
    // Re-throw so Next.js returns 500 and Svix retries.
    throw err;
  }
}
```

### Next.js (pages router)

```typescript
// pages/api/webhooks/agg.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { parseWebhookEvent, WebhookVerificationError } from "@agg-build/sdk/server";

// Disable Next's body parser so we can read the raw text.
export const config = { api: { bodyParser: false } };

async function readRawBody(req: NextApiRequest): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c as Buffer);
  return Buffer.concat(chunks).toString("utf8");
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const event = parseWebhookEvent(
      await readRawBody(req),
      req.headers,
      process.env.AGG_WEBHOOK_SECRET!,
    );
    // switch on event.type ...
    res.status(200).json({ received: true });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return res.status(401).json({ error: err.message });
    }
    throw err;
  }
}
```

### Express

```typescript
import express from "express";
import { parseWebhookEvent, WebhookVerificationError } from "@agg-build/sdk/server";

const app = express();

// IMPORTANT: express.raw() — not express.json() — so we get the unparsed body
// for signature verification.
app.post("/webhooks/agg", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = parseWebhookEvent(
      req.body.toString("utf8"),
      req.headers as Record<string, string>,
      process.env.AGG_WEBHOOK_SECRET!,
    );

    // switch on event.type ...

    res.status(200).json({ received: true });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return res.status(401).json({ error: err.message });
    }
    res.status(500).json({ error: "handler failed" });
  }
});
```

### Fastify

```typescript
import Fastify from "fastify";
import { parseWebhookEvent, WebhookVerificationError } from "@agg-build/sdk/server";

const app = Fastify();

// Capture the raw body before Fastify's JSON parser consumes it.
app.addContentTypeParser(
  "application/json",
  { parseAs: "string" },
  (_req, body, done) => done(null, body),
);

app.post("/webhooks/agg", async (req, reply) => {
  try {
    const event = parseWebhookEvent(
      req.body as string,
      req.headers as Record<string, string>,
      process.env.AGG_WEBHOOK_SECRET!,
    );
    // switch on event.type ...
    return { received: true };
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      reply.status(401);
      return { error: err.message };
    }
    throw err;
  }
});
```

### Cloudflare Workers

```typescript
import { parseWebhookEventAsync, WebhookVerificationError } from "@agg-build/sdk/server";

export interface Env {
  AGG_WEBHOOK_SECRET: string;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    try {
      const event = await parseWebhookEventAsync(
        await req.text(),
        Object.fromEntries(req.headers),
        env.AGG_WEBHOOK_SECRET,
      );
      // switch on event.type ...
      return Response.json({ received: true });
    } catch (err) {
      if (err instanceof WebhookVerificationError) {
        return Response.json({ error: err.message }, { status: 401 });
      }
      throw err;
    }
  },
};
```

### Vercel Edge / Bun / Deno / Hono

Any runtime that ships the Fetch API uses the same shape as the Cloudflare Workers example — call
`parseWebhookEventAsync` and return a `Response`. Hono example:

```typescript
import { Hono } from "hono";
import { parseWebhookEventAsync, WebhookVerificationError } from "@agg-build/sdk/server";

const app = new Hono();

app.post("/webhooks/agg", async (c) => {
  try {
    const event = await parseWebhookEventAsync(
      await c.req.text(),
      Object.fromEntries(c.req.raw.headers),
      c.env.AGG_WEBHOOK_SECRET,
    );
    // switch on event.type ...
    return c.json({ received: true });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return c.json({ error: err.message }, 401);
    }
    throw err;
  }
});
```

### AWS Lambda (API Gateway / Function URL)

```typescript
import { parseWebhookEvent, WebhookVerificationError } from "@agg-build/sdk/server";
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";

export const handler = async (
  evt: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyResultV2> => {
  // API Gateway base64-encodes binary bodies; decode if present.
  const rawBody = evt.isBase64Encoded
    ? Buffer.from(evt.body ?? "", "base64").toString("utf8")
    : (evt.body ?? "");

  try {
    const event = parseWebhookEvent(rawBody, evt.headers, process.env.AGG_WEBHOOK_SECRET!);
    // switch on event.type ...
    return { statusCode: 200, body: JSON.stringify({ received: true }) };
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return { statusCode: 401, body: JSON.stringify({ error: err.message }) };
    }
    throw err;
  }
};
```

### Plain Node.js `http`

```typescript
import { createServer } from "node:http";
import { parseWebhookEvent, WebhookVerificationError } from "@agg-build/sdk/server";

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

  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c as Buffer);
  const rawBody = Buffer.concat(chunks).toString("utf8");

  try {
    const event = parseWebhookEvent(
      rawBody,
      req.headers as Record<string, string>,
      process.env.AGG_WEBHOOK_SECRET!,
    );
    // switch on event.type ...
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ received: true }));
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      res.writeHead(401, { "content-type": "application/json" });
      return res.end(JSON.stringify({ error: err.message }));
    }
    res.writeHead(500);
    res.end();
  }
}).listen(3000);
```

## Idempotency

Svix retries failed deliveries up to ~24h with exponential backoff. Each retry uses the **same**
`Webhook-Id` header, so your handler must be idempotent — track processed IDs in Redis, Postgres,
or any shared store and skip duplicates.

```typescript
async function handle(req: Request) {
  const event = await parseWebhookEventAsync(
    await req.text(),
    Object.fromEntries(req.headers),
    process.env.AGG_WEBHOOK_SECRET!,
  );
  const webhookId = req.headers.get("webhook-id")!;

  // Skip if we've already processed this webhook-id within the last 24h.
  if (await redis.exists(`agg:wh:done:${webhookId}`)) {
    return Response.json({ received: true, deduplicated: true });
  }

  // Process and mark seen (TTL ≥ Svix's 24h retry window).
  await processEvent(event);
  await redis.setex(`agg:wh:done:${webhookId}`, 86_400, "1");

  return Response.json({ received: true });
}
```

Pick a store that fits your stack — Redis for low latency, Postgres `INSERT … ON CONFLICT` if you
already have a transactional DB write per event, KV / Durable Objects on Cloudflare, etc.

## Non-TypeScript implementations

If your backend isn't TypeScript, here's the raw scheme in Python and Go.

<Tabs>
  <Tab title="Python">
    <div key="python" className="[&>p]:mb-4 [&>p:last-child]:mb-0">
      ```python
      import hmac
      import hashlib
      import base64
      import time

      def verify_webhook(payload: bytes, headers: dict, secret: str) -> bool:
          msg_id = headers.get("webhook-id")
          timestamp = headers.get("webhook-timestamp")
          signatures = headers.get("webhook-signature")
          if not all([msg_id, timestamp, signatures]):
              return False
          if abs(time.time() - int(timestamp)) > 300:
              return False

          secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
          signed_content = f"{msg_id}.{timestamp}.{payload.decode()}"
          expected = base64.b64encode(
              hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
          ).decode()
          expected_sig = f"v1,{expected}"

          return any(hmac.compare_digest(sig, expected_sig) for sig in signatures.split(" "))
      ```
    </div>
  </Tab>
  <Tab title="Go">
    <div key="go" className="[&>p]:mb-4 [&>p:last-child]:mb-0">
      ```go
      package webhook

      import (
          "crypto/hmac"
          "crypto/sha256"
          "encoding/base64"
          "math"
          "strings"
          "strconv"
          "time"
      )

      func Verify(payload []byte, headers map[string]string, secret string) bool {
          msgID := headers["webhook-id"]
          timestamp := headers["webhook-timestamp"]
          signatures := headers["webhook-signature"]
          if msgID == "" || timestamp == "" || signatures == "" {
              return false
          }
          ts, err := strconv.ParseInt(timestamp, 10, 64)
          if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
              return false
          }

          secretBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
          if err != nil {
              return false
          }
          signedContent := msgID + "." + timestamp + "." + string(payload)
          mac := hmac.New(sha256.New, secretBytes)
          mac.Write([]byte(signedContent))
          expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))

          for _, sig := range strings.Split(signatures, " ") {
              if hmac.Equal([]byte(sig), []byte(expected)) {
                  return true
              }
          }
          return false
      }
      ```
    </div>
  </Tab>
</Tabs>
