Skip to main content
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.
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.

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.
Always read the raw body for signature verification. Parsing JSON first and re-stringifying changes whitespace and breaks the HMAC.

Framework recipes

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

Next.js (app router)

// 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)

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

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

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

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:
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)

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

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.
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.
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(" "))