Webhooks
Deliver any platform event to your own HTTPS endpoints -- signed, retried, and logged. One registry at api.hanzo.ai/v1/webhooks.
Webhooks
Webhooks push platform events to your own HTTPS endpoints as they happen. You register an endpoint, choose which event subjects it receives, and Hanzo delivers each matching event with a signed, retried POST. Every service that publishes to the platform event bus is deliverable this way -- one registry, one delivery contract, for all global events.
There is one place for this: api.hanzo.ai/v1/webhooks. (Webhooks are a first-class resource, not nested under any single service.)
Register an endpoint
curl -X POST https://api.hanzo.ai/v1/webhooks \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/hanzo",
"events": ["commerce.order.*", "commerce.checkout.*"],
"description": "Order + checkout events"
}'The response includes a secret -- shown exactly once. Store it now; you use it to verify every delivery. If you lose it, rotate (below) rather than recreate.
events is a list of subject patterns using NATS wildcard semantics:
*matches exactly one token:commerce.order.*matchescommerce.order.createdbut notcommerce.order.line.added.>matches one or more trailing tokens:commerce.>matches everycommerce.subject.
Only events belonging to your own organization are ever delivered to your endpoints -- there is no cross-tenant delivery.
Manage endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /v1/webhooks | List your endpoints (with 7-day delivery/failure counts) |
POST | /v1/webhooks | Register an endpoint (returns the reveal-once secret) |
GET | /v1/webhooks/:id | Fetch one endpoint |
PATCH | /v1/webhooks/:id | Update url, events, description, or status |
DELETE | /v1/webhooks/:id | Remove an endpoint |
Set "status": "disabled" via PATCH to pause delivery without losing the endpoint or its history; "active" resumes it.
Verify a delivery
Every delivery carries three headers:
| Header | Value |
|---|---|
X-Webhook-Signature | t=<unix>,v1=<hex hmac-sha256> |
X-Webhook-Event | the event subject, e.g. commerce.order.completed |
X-Webhook-Delivery | a UUID, stable across retries of the same event (use it to dedupe) |
The signature is computed over the timestamped raw body: HMAC-SHA256(secret, "<t>.<raw-body>"), hex-encoded. Recompute it from the raw request body -- not a re-serialized copy -- and compare in constant time. Reject anything that doesn't match.
import crypto from 'node:crypto'
export function verifyWebhook(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=')),
)
const t = parts.t
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1 ?? '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// VerifyWebhook reports whether sig ("t=…,v1=…") authenticates rawBody.
func VerifyWebhook(secret, sig string, rawBody []byte) bool {
var t, v1 string
for _, p := range strings.Split(sig, ",") {
if kv := strings.SplitN(p, "=", 2); len(kv) == 2 {
switch kv[0] {
case "t":
t = kv[1]
case "v1":
v1 = kv[1]
}
}
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}Delivery, retries, and logs
Delivery is at-least-once. A fresh timestamp and signature are computed for each attempt, so a slow retry is never rejected as stale. Failed attempts back off:
- Up to 3 attempts at roughly
1s,5s,25s(plus jitter), 10s timeout each. - Retries only on a network error,
5xx, or429. Any4xxis treated as permanent and stops immediately -- fix your endpoint, then resend with a test (below).
Because delivery is at-least-once and retries share one X-Webhook-Delivery id, make your handler idempotent: dedupe on that id, and return 2xx as soon as you've durably accepted the event.
Every attempt is logged. Read them back:
# recent attempts, newest first
curl "https://api.hanzo.ai/v1/webhooks/$ID/deliveries?limit=50" \
-H "Authorization: Bearer $HANZO_API_KEY"
# only failures
curl "https://api.hanzo.ai/v1/webhooks/$ID/deliveries?status=failed" \
-H "Authorization: Bearer $HANZO_API_KEY"Each row carries the delivery id, subject, attempt number, status, HTTP status, duration, and any error.
Test an endpoint
Send a synthetic webhook.test event to any endpoint -- including a disabled one -- and get the result inline:
curl -X POST "https://api.hanzo.ai/v1/webhooks/$ID/test" \
-H "Authorization: Bearer $HANZO_API_KEY"
# → {"delivered":true,"httpStatus":200,"durationMs":142}The test runs through the exact signing and POST path a real delivery uses (single attempt, no retry ladder), so a passing test means live events will verify the same way. The attempt is recorded in the delivery log.
Rotate the secret
curl -X POST "https://api.hanzo.ai/v1/webhooks/$ID/rotate-secret" \
-H "Authorization: Bearer $HANZO_API_KEY"
# → {"secret":"whsec_…"} (shown once)The old secret is invalid immediately. Update your verifier to the new value before rotating in production, or briefly accept either during the switch.
Events you can subscribe to
Subjects follow <service>.<object>.<action>. Live today:
commerce.order.created,commerce.order.completed,commerce.order.refundedcommerce.checkout.started
Subscribe with a pattern to future-proof: commerce.> receives every commerce subject, including ones added later. More event sources (agents, functions, identity, world) publish to the same bus and become deliverable through the same registry as they come online.
In the console
The Cloud console has a Webhooks product under Dev: register and enable endpoints, reveal and rotate secrets, send a test delivery and see the result inline, and browse per-endpoint delivery logs with a failures-only filter. The signature scheme and copy-paste verifiers above are shown right in the endpoint's Security panel.
How is this guide?
Last updated on