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

# Verifying Webhook Signatures

> Verify that webhook deliveries are authentic and untampered, in any framework

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 theme={null}
// 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 theme={null}
// 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 theme={null}
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 theme={null}
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 theme={null}
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 theme={null}
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 theme={null}
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 theme={null}
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 theme={null}
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">
    ```python theme={null}
    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(" "))
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    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
    }
    ```
  </Tab>
</Tabs>
