Receive webhooks
Add a webhook endpoint, verify every request with the AbuseZero Node.js SDK or in any language, handle retries and duplicates, and confirm deliveries.
This guide connects one HTTPS endpoint on your server to AbuseZero webhooks. When you finish, your endpoint receives signed events, refuses anything that isn't from AbuseZero, and handles retries without doing the same work twice.
Before you begin
- The owner or admin role in the workspace. Only they can add endpoints.
- An HTTPS URL on a public host name that your server answers, such as
https://example.com/webhooks/abusezero. - The AbuseZero Node.js SDK (
npm install abusezero), or any language with HMAC-SHA256. See Verify in other languages.
Add an endpoint
- In the dashboard, open Developers → Webhooks and select Add endpoint.
- Enter the Endpoint URL.
- Under Events, check the events you need.
evaluation.completedis checked to start. - Optionally check Denied evaluations only, and choose Benefits to limit the endpoint to them.
- Select Add endpoint, then copy the signing secret.
The signing secret starts with whsk_ and is shown once. Store it where your server reads secrets, for example as ABUSEZERO_WEBHOOK_SECRET. Each endpoint has its own secret.
Read the raw request body
The signature covers the exact bytes AbuseZero sent. Read the body as text or bytes, and verify it before you parse it. A body that was parsed and serialized again won't match its signature.
| Framework | Raw body |
|---|---|
| Next.js route handler | await request.text() |
| Express | express.raw({ type: "application/json" }) on the route, then req.body |
Verify the signature
Pass the raw body, the request headers and the signing secret to verifyWebhook(). It returns the event, or throws WebhookVerificationError when the request isn't from AbuseZero, was changed, or is more than 5 minutes old.
import { verifyWebhook, WebhookVerificationError } from "abusezero/server";
import { enqueue } from "@/lib/jobs";
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = verifyWebhook({ payload, headers: request.headers, secret: process.env.ABUSEZERO_WEBHOOK_SECRET! });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
throw error;
}
await enqueue("abusezero-event", event); // Your own queue. Handle the event after replying.
return new Response(null, { status: 204 });
}enqueue stands for your own job queue.
Verify in other languages
Every request carries three AbuseZero signature headers. Verify them in this order:
- Read
abusezero-webhook-id,abusezero-webhook-timestampandabusezero-webhook-signature. Refuse the request if one is missing. - Refuse the request if
abusezero-webhook-timestamp, in Unix seconds, is more than 5 minutes from your server's clock. - Build the signed content:
{abusezero-webhook-id}.{abusezero-webhook-timestamp}.{raw body}. - Take the signing secret without its
whsk_prefix and decode it from base64. That is the key. - Compute HMAC-SHA256 of the signed content with the key, and encode it as base64.
abusezero-webhook-signatureholds one or more values separated by spaces, each starting withv1,. Compare your result with each value afterv1,, in constant time. Accept the request if one matches.- Parse the body, and check that its
idequalsabusezero-webhook-id.
import base64, hashlib, hmac, json, time
def verify_abusezero_webhook(body: bytes, headers, secret: str, tolerance: int = 300) -> dict:
event_id = headers["abusezero-webhook-id"]
timestamp = headers["abusezero-webhook-timestamp"]
if abs(time.time() - int(timestamp)) > tolerance:
raise ValueError("The webhook timestamp is outside the tolerance.")
key = base64.b64decode(secret.removeprefix("whsk_"))
signed = f"{event_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
offered = [value[3:] for value in headers["abusezero-webhook-signature"].split(" ") if value.startswith("v1,")]
if not any(hmac.compare_digest(expected, value) for value in offered):
raise ValueError("The webhook signature doesn't match.")
event = json.loads(body)
if event.get("id") != event_id:
raise ValueError("The webhook body doesn't match its headers.")
return eventReply quickly, then process
Reply with a 2xx status within 10 seconds. A slower reply counts as a failed attempt and is retried, even if your server finished the work. Put the event on your own queue, reply, then process it.
- Reply
2xxto event types your endpoint doesn't handle. New event types can be added withinv1. - A redirect is not followed and counts as a failed attempt. Use the final URL when you add the endpoint.
- The first 1 KB of your reply is kept in the delivery log. A short reason in the body makes failed attempts easier to read later.
Skip duplicates
Delivery is at least once. A retry, a resend or a network failure after your server replied can bring the same event again, always with the same id. Record each id you finish, and skip it the next time.
import type { WebhookEvent } from "abusezero/server";
import { db } from "@/lib/db";
export async function handleAbuseZeroEvent(event: WebhookEvent) {
await db.transaction(async tx => {
const first = await tx.insertIfAbsent("handled_abusezero_events", { id: event.id });
if (!first) return; // Already handled.
switch (event.type) {
case "evaluation.completed": return onEvaluation(tx, event.data);
case "outcome.updated": return onOutcome(tx, event.data);
default: return; // Event types you don't handle.
}
});
}db, insertIfAbsent, onEvaluation and onOutcome stand for your own code. Events can also arrive out of order. Act on the state each object carries, such as an outcome's state, rather than on arrival order.
Send a test event
In Developers → Webhooks, open the endpoint's actions menu and select Send test event. From the dashboard it's usually sent within a minute. Through the AbuseZero REST API it's sent right away:
const delivery = await abusezero.call("/v1/webhook-endpoints/whe_7d2e9a40-3c1b-4f65-8e27-b9a0c4d61f53/test", {
method: "POST",
idempotencyKey: "webhook-test-2026-10-31",
});The AbuseZero CLI command is abusezero webhook-endpoints test, and the AbuseZero MCP server tool is abusezero_send_webhook_test. Test events are free, are sent even to a paused endpoint, are attempted once, and each endpoint accepts 10 an hour.
Check the delivery log
Select Deliveries on the endpoint's row. The test event shows Delivered with your endpoint's status code. Open it with View to see the exact request body, the abusezero-webhook-id header and every attempt.
To read the log from code, call GET /v1/webhook-deliveries with endpoint_id, or run abusezero webhook-deliveries list --endpoint-id whe_…. A delivery that failed for a reason on your side shows the error and your reply. Delivery errors lists each one.
You now have an endpoint that receives signed AbuseZero events, refuses anything else, replies within its time limit and processes each event once.
Rotate the signing secret
Rotate a secret when it may have leaked, or when someone who knew it leaves your team.
- In Developers → Webhooks, open the endpoint's actions menu and select Rotate signing secret.
- Type
RESETand confirm. Copy the new secret. - Deploy it beside the old one. For the next 24 hours, every request is signed with both, so pass both to
verifyWebhook():
const event = verifyWebhook({
payload,
headers: request.headers,
secret: [process.env.ABUSEZERO_WEBHOOK_SECRET!, process.env.ABUSEZERO_WEBHOOK_SECRET_PREVIOUS!],
});- After 24 hours, remove the old secret.
The workspace owner and admins receive an email for every rotation.
How is this guide?
Last updated on