Skip to content
Zever
Home

Webhooks

Deliver signed outbound webhooks with per-target secrets over HTTP or the retrying queue.

2 min readEdit on GitHub

Use webhook for signed outbound event delivery. The interface covers Register, Unregister, and Deliver fan-out. Signatures use t=<timestamp>,v1=<hex-hmac> over <timestamp>.<payload>, verified with hmac.Equal inside a timestamp window. There is no top-level API key in Options: every target gets its own secret at Register time, so one compromised secret never exposes every integration.

Terminal window
WEBHOOK_ADAPTER=queue
WEBHOOK_TIMEOUT=10s
WEBHOOK_MAX_RETRIES=3
WEBHOOK_QUEUE_ADAPTER=memory
WEBHOOK_QUEUE_OPTS_VISIBILITY_TIMEOUT=30s
WEBHOOK_QUEUE_OPTS_POLL_TIMEOUT=1s
WEBHOOK_QUEUE_OPTS_BUFFER=100
WEBHOOK_DEAD_LETTER_TOPIC=webhook.dead
WEBHOOK_REPLAY_TOLERANCE=300000000000
Terminal window
WEBHOOK_ADAPTER=queue
WEBHOOK_MAXRETRIES=3
WEBHOOK_REPLAYTOLERANCE=5m
BOOKINGS_WEBHOOK_CREATED_TARGET=https://partner.example.com/hooks
BOOKINGS_WEBHOOK_CREATED_SECRET=per-target-secret

Keep secrets out of the file. Validate rejects negative timeout, max_retries, and replay_tolerance. The queue adapter, not core, enforces the queue rules (adapter required, visibility exceeding timeout). All keys: webhook config reference.

main.go
package main
import (
"context"
"fmt"
webhookhttp "github.com/zenta-dev/zever/adapters/webhook/http"
"github.com/zenta-dev/zever/config"
"github.com/zenta-dev/zever/container"
)
func main() {
webhookhttp.Register()
wh, _ := container.New(config.Default()).Webhook()
defer wh.Close()
target := "" // empty means skip, like RegisterDemoTargets
if target == "" {
fmt.Println("delivery skipped: no target configured")
return
}
_ = wh.Register(context.Background(), "booking.created", target, "secret")
}
delivery skipped: no target configured

With a target set and the http adapter, Deliver POSTs the signed payload to every subscriber of the event, with the signature in the X-Hub-Signature-256 header (the queue adapter uses X-Webhook-Signature and verifies it before delivery). Webhook() resolves lazily, caches per container, and retries after errors; Close() error takes no context. See the container.

The bookings example sets adapter: queue. Demo targets come from the environment only (BOOKINGS_WEBHOOK_CREATED_TARGET and _SECRET, plus the CANCELLED pair). RegisterDemoTargets skips pairs with an empty target, so delivery is quietly skipped when unconfigured and the worker runs without them.

Adapter Package Use when
http adapters/webhook/http Default. Direct POST with no durability. A crash mid-delivery loses the event.
queue adapters/webhook/queue Delivery rides the queue: at-least-once, exponential redelivery backoff (500ms base, 72h cap, up to max_retries), then the dead-letter topic.

With http, N replicas mean N independent retry loops. Prefer queue so retries funnel through shared infrastructure, with a dead-letter topic for exhausted deliveries (webhook.dead in the demo). The bookings README notes the queue variant needs its queue reference wired before doctor goes green.

  • Never log per-target secrets, and never log raw option maps. Use config.RedactedServices.
  • Verifiers must check both the HMAC (constant-time) and the timestamp inside replay_tolerance (default 5m).
  • Keep allow_private_targets false in production to avoid SSRF into internal URLs.
  • The container closes only resolved services and joins errors via errors.Join.
The webhook silently does nothing

The target env var is empty, so registration was skipped by design; this is not an error path. Set BOOKINGS_WEBHOOK_CREATED_TARGET and its secret, then restart the worker.

Signatures verify but replays succeed

The verifier checks the HMAC but not the timestamp window. Enforce replay_tolerance on the embedded t= value so hour-old payloads are rejected.

Queue adapter open fails

queue_adapter is unset, or visibility_timeout does not exceed timeout. Mirror the bookings shape (memory adapter, 30s visibility over a 10s timeout).