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