Create a Webhook
Register an endpoint and choose which events to subscribe to. The secret is returned once — store it securely in your environment variables.
curl -X POST https://app.sepurux.dev/api/backend/v1/webhooks \
-H "Content-Type: application/json" \
-H "X-API-Key: $SEPURUX_API_KEY" \
-H "X-Project-Id: $SEPURUX_PROJECT_ID" \
-d '{
"url": "https://your-server.example.com/hooks/sepurux",
"events": ["run.completed", "trace.created"],
"is_enabled": true
}'
# Response — store the secret; it is only returned once
{
"webhook_id": "wh_...",
"url": "https://your-server.example.com/hooks/sepurux",
"events": ["run.completed", "trace.created"],
"is_enabled": true,
"secret": "whsec_..."
}events array
Subscribe to specific events like ["run.completed"] or use ["*"] to receive all events.
secret
Store the returned whsec_... value as an environment variable. It is never shown again; rotate it if compromised.
is_enabled
Set false to pause deliveries without deleting the webhook. Toggle back to true to resume.
Event Types
Subscribe to one or more of these events. Use * to receive all events for the project.
| Event | Description | data fields |
|---|---|---|
trace.created | A trace was uploaded to the project. | trace_id, source, trace_version, validation_status, sdk_meta |
run.created | A reliability run was created (before it is queued for execution). | run_id, trace_id, campaign_id, mutation_pack_id, policy_pack_id, status |
run.queued | A run was accepted and added to the worker queue. | run_id, trace_id, campaign_id |
run.completed | A run finished. Includes pass rate, failure counts, and policy event totals. | run_id, status, attempt_count, passed_count, failed_count, unsafe_count, pass_rate, policy_events_count, security_events_count |
run.pack_queued | A run-pack request was accepted (campaign-level multi-run submission). | run_id, campaign_id, mutation_pack_id |
run.replay_queued | A trace replay run was enqueued. | run_id, trace_id, campaign_id |
campaign.created | A new campaign was created in the project. | campaign_id, name |
ci.decision.pass | A CI run completed and the gate decision is pass. | run_id, decision, pass_rate, unsafe_attempts, failures |
ci.decision.fail | A CI run completed and the gate decision is fail. | run_id, decision, pass_rate, unsafe_attempts, failures |
Payload Shape
Every delivery is a JSON POST with a consistent envelope. The data field varies by event type.
{
"event_type": "run.completed",
"project_id": "<project_uuid>",
"created_at": "2025-11-04T14:23:01.123456+00:00",
"data": {
"run_id": "<run_uuid>",
"status": "done",
"attempt_count": 40,
"passed_count": 36,
"failed_count": 3,
"unsafe_count": 1,
"pass_rate": 0.9,
"policy_events_count": 2,
"security_events_count": 1
}
}Signature Verification
Every delivery includes an X-Sepurux-Signature header. Verify it before processing the payload to reject forged or replayed requests.
import hashlib
import hmac
def verify_sepurux_signature(
raw_body: bytes,
secret: str,
signature_header: str,
) -> bool:
"""Return True if the X-Sepurux-Signature header matches the payload."""
expected = "sha256=" + hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# In a FastAPI handler:
from fastapi import Request, HTTPException
@app.post("/hooks/sepurux")
async def handle_webhook(request: Request):
body = await request.body()
sig = request.headers.get("X-Sepurux-Signature", "")
if not verify_sepurux_signature(body, WEBHOOK_SECRET, sig):
raise HTTPException(status_code=401, detail="Invalid signature")
event = await request.json()
print(event["event_type"], event["data"])
return {"ok": True}import crypto from "crypto";
function verifySepuruxSignature(
rawBody: Buffer,
secret: string,
signatureHeader: string,
): boolean {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader),
);
}
// In an Express handler:
app.post(
"/hooks/sepurux",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-sepurux-signature"] as string;
if (!verifySepuruxSignature(req.body, WEBHOOK_SECRET, sig)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString());
console.log(event.event_type, event.data);
res.json({ ok: true });
},
);hmac.compare_digest / crypto.timingSafeEqual) to prevent timing attacks. Never compare signature strings with ==.Rotate Secret
If a secret is compromised or you want to rotate it on a schedule, use the rotate endpoint. Update your receiver immediately — old signatures will no longer verify.
curl -X POST https://app.sepurux.dev/api/backend/v1/webhooks/<webhook_id>/rotate-secret \
-H "X-API-Key: $SEPURUX_API_KEY" \
-H "X-Project-Id: $SEPURUX_PROJECT_ID"
# Response
{
"webhook_id": "wh_...",
"secret": "whsec_..." # new secret — update your receiver immediately
}Delivery Tips
Best practices for reliable webhook consumption.
Respond quickly
Return HTTP 2xx within 5 seconds. If processing takes longer, accept the payload, enqueue work, and respond immediately.
Retry behaviour
Sepurux retries up to 3 times with exponential back-off (0.5s, 1s, 2s). Design your handler to be idempotent.
Idempotency
Use event_type + data.run_id (or trace_id) as a natural deduplication key in case a delivery is retried.
HTTPS only
Webhook URLs must use https://. Plain http:// endpoints are rejected at registration time.
Disable vs delete
Set is_enabled: false to pause deliveries during maintenance windows without losing your endpoint configuration.
last_error field
If a delivery fails all retries, the webhook record's last_error field captures the HTTP status or connection error for debugging.
