Webhooks Quickstart
Register a target, subscribe to an event, receive a signed payload, verify it and respond 200, end to end.
Zuse Bifrost delivers webhook POSTs to any publicly reachable HTTPS URL you register. This page walks the happy path from zero.
Register a target
In the app, open Settings → Webhooks and add an endpoint: a name, the https:// URL, the events to subscribe, and optionally the project it should be limited to and the addresses it may resolve to. Over the REST API the same registration is one call on the API host, behind the webhook-endpoint:create ability:
POST /v1/webhook-endpoints HTTP/1.1
Authorization: Bearer $ZUSEGENIUS_TOKEN
Idempotency-Key: 1b4e28ba-2fa1-11d2-883f-0016d3cca427
Content-Type: application/json
{
"name": "CI listener",
"url": "https://hooks.example.com/zusegenius",
"events": ["project.created", "project.status_changed"]
}The response includes a one-time secret field. Store it: you need it to verify signatures and you cannot retrieve it again.
The URL has to be https:// on a public host. A loopback, private or
link-local address, a local name, or a public name that resolves to a private
address is refused at registration, and so is a name that does not resolve at
all. See security.
Receive the POST
Every delivery lands on your URL as a JSON POST:
POST /zusegenius HTTP/1.1
ZG-Event-Id: 01HXDELIVERY000000000TST
ZG-Event-Name: project.created
ZG-Signature: t=1762041600,v1=4a2b...
Content-Type: application/json
User-Agent: ZuseBifrost-Webhooks/1.0
{"id":"evt_01HX...","type":"project.created",...}Verify the signature
Every payload is signed with HMAC-SHA256 over <timestamp>.<raw_body>. The signature verification page has the recipe in three languages. The short form in Node:
import crypto from "node:crypto";
function verify(header: string, body: string, secret: string): boolean {
const [tPart, v1Part] = header.split(",");
const t = tPart.split("=")[1];
const v1 = v1Part.split("=")[1];
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Reject the request if the signature does not match or the timestamp is more than five minutes old.
Respond 200 quickly
Return 200 OK well inside the 30-second budget. Anything else counts as a failure and triggers a retry. If your work takes longer, enqueue a background job and respond immediately.
De-duplicate on the event id
The ZG-Event-Id header (and the id in the payload, with its evt_ prefix) is unique per event. Store it and short-circuit if you have seen it before: retries can make the same event arrive more than once, and two endpoints subscribed to the same event each receive it.
Prove it works
Before any real event fires, send a test delivery from the endpoint's menu in Settings → Webhooks. It posts a synthetic project.created envelope whose data carries synthetic: true, signed with the current secret and routed through the same queue as a real event, so DNS, TLS and your verification are exercised end to end. The delivery and its outcome appear in Settings → Webhooks → Deliveries like any other.
What next
How is this guide?
Last updated on
Webhooks & Events
Event-driven HTTPS notifications from Zuse Bifrost to your server, CI or anything that takes JSON over POST, with retries and replay.
Signature Verification
HMAC-SHA256 verification of every delivery's ZG-Signature header, in a Stripe-compatible scheme, and how a rotation replaces the secret.