Webhooks
Overview
LevelFour uses Svix for webhook delivery. When events occur in your account, LevelFour sends HTTPS POST requests to your registered endpoints with a signed payload.
Registering an Endpoint
endpoint = client.webhooks.register(
url="https://your-app.com/webhooks/levelfour",
event_types=["recommendation.accepted", "optimization.completed"],
description="Production webhook",
)Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS endpoint URL |
event_types | string[] | Yes | Events to subscribe to |
description | string | No | Human-readable description |
Managing Endpoints
List Endpoints
endpoints = client.webhooks.list()Delete an Endpoint
client.webhooks.delete("ep_123")Event Types
| Event | Description |
|---|---|
recommendation.accepted | A savings recommendation was accepted |
recommendation.rejected | A savings recommendation was rejected |
optimization.started | An automated optimization has started processing |
optimization.completed | An automated optimization finished successfully |
optimization.failed | An automated optimization failed |
Event Payloads
recommendation.accepted / recommendation.rejected
{
"recommendation_id": "rec_123",
"saving_acceptance": "accepted",
"saving_accepted_by": "user@example.com",
"saving_accepted_at": "2025-10-15T10:00:00",
"rejection_reason": null,
"rejection_explanation": null,
"status": "optimized"
}For rejected events, rejection_reason and rejection_explanation will be populated, and status will be rejected.
optimization.started
{
"recommendation_id": "rec_123",
"status": "processing",
"implementation_method": "one-click",
"completed_at": "2025-10-15T10:05:00Z",
"message": "Optimization started"
}The implementation_method field can be: one-click, iac, one-click-plus-iac, or manual.
optimization.completed
{
"recommendation_id": "rec_123",
"status": "optimized",
"implementation_method": "one-click"
}optimization.failed
{
"recommendation_id": "rec_123",
"status": "failed",
"implementation_method": "one-click"
}Signature Verification
Every webhook request includes three headers for HMAC-SHA256 signature verification:
| Header | Description |
|---|---|
webhook-id | Unique message ID (for deduplication) |
webhook-timestamp | Unix timestamp in seconds |
webhook-signature | v1,<base64-encoded-signature> |
Legacy Svix headers (svix-id, svix-timestamp, svix-signature) are also supported.
Python
from levelfour.webhooks.verifier import WebhookVerifier, WebhookVerificationError
verifier = WebhookVerifier("whsec_your_signing_secret")
try:
payload = verifier.verify(
payload=request_body,
headers={
"webhook-id": headers["webhook-id"],
"webhook-timestamp": headers["webhook-timestamp"],
"webhook-signature": headers["webhook-signature"],
},
)
handle_event(payload)
except WebhookVerificationError:
return Response(status_code=400)TypeScript
import { WebhookVerifier, WebhookVerificationError } from "levelfour";
const verifier = new WebhookVerifier("whsec_your_signing_secret");
try {
const payload = verifier.verify(requestBody, {
"webhook-id": headers["webhook-id"],
"webhook-timestamp": headers["webhook-timestamp"],
"webhook-signature": headers["webhook-signature"],
});
handleEvent(payload);
} catch (err) {
if (err instanceof WebhookVerificationError) {
res.status(400).json({ error: "Invalid signature" });
}
}Go
import (
"io"
"net/http"
"github.com/LevelFourAI/levelfour-go/levelfour/webhooks"
)
verifier, err := webhooks.NewVerifier("whsec_your_signing_secret")
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
payload, err := verifier.Verify(r.Header, body)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
handleEvent(payload)
w.WriteHeader(http.StatusOK)
})Signature Algorithm
The signature is computed as:
base64(HMAC-SHA256(base64_decode(secret), "{webhook-id}.{webhook-timestamp}.{body}"))Signing secrets are prefixed with whsec_. The SDK verifiers strip this prefix automatically before base64-decoding the secret bytes.
Timestamp Tolerance
By default, the verifiers reject messages with timestamps more than 5 minutes from the current time. This prevents replay attacks. You can customize the tolerance:
payload = verifier.verify(body, headers, tolerance_seconds=600)Retry Behavior
Svix automatically retries failed deliveries using exponential backoff. A delivery is considered failed if your endpoint:
- Returns a non-2xx HTTP status code
- Does not respond within the timeout
- Is unreachable
Implement idempotent handlers using the webhook-id header for deduplication.
Security Best Practices
- Always verify webhook signatures before processing events
- Use HTTPS endpoints only
- Respond with
2xxquickly and do heavy processing asynchronously - Store your signing secret securely (environment variable or secrets manager)
- Implement idempotent handlers since webhooks may be retried on delivery failure