Docs / Webhooks

Real-time event notifications.

Subscribe to project events and receive signed HTTP POST payloads to any public HTTPS endpoint. Verify every delivery with HMAC-SHA256.

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.

bash
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.

EventDescription
trace.createdA trace was uploaded to the project.
run.createdA reliability run was created (before it is queued for execution).
run.queuedA run was accepted and added to the worker queue.
run.completedA run finished. Includes pass rate, failure counts, and policy event totals.
run.pack_queuedA run-pack request was accepted (campaign-level multi-run submission).
run.replay_queuedA trace replay run was enqueued.
campaign.createdA new campaign was created in the project.
ci.decision.passA CI run completed and the gate decision is pass.
ci.decision.failA CI run completed and the gate decision is fail.

Payload Shape

Every delivery is a JSON POST with a consistent envelope. The data field varies by event type.

json
{
  "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
  }
}
The JSON body is serialised with sorted keys and compact separators before signing. Use the raw request body bytes — not a re-serialised version — when computing the verification digest.

Signature Verification

Every delivery includes an X-Sepurux-Signature header. Verify it before processing the payload to reject forged or replayed requests.

python
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}
typescript
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 });
  },
);
Always use a constant-time comparison (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.

bash
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.