Webhooks
Webhooks let you react to Certivu events as they happen without polling the API. When a record is signed, a tamper is detected, or quota crosses a threshold, Certivu POSTs a signed JSON payload to your registered endpoint.
Available on Growth, Scale, and Enterprise plans. Up to 10 endpoints per org.
How it works
Section titled “How it works”- You register an HTTPS endpoint from the dashboard or via
POST /v1/webhooks. - Certivu stores the endpoint URL and a signing secret (shown once at creation).
- When an event fires, Certivu delivers a signed
POSTto your URL within the same request cycle — no queue delay. - Your server verifies the signature, processes the event, and returns any
2xxstatus. - Non-
2xxresponses are recorded as failures. After 5 consecutive failures the endpoint is automatically disabled.
Event types
Section titled “Event types”| Event | Fired when |
|---|---|
record.created | A provenance record is signed and stored |
verify.attempted | Any verification runs against your records |
verify.tamper_detected | A verification returns tampered: true |
quota.warning | Signing usage crosses 80% of your plan limit |
quota.limit | Plan limit reached — signing is now blocked (402) |
generator.revoked | One of your generators is revoked |
Subscribe only to the events you need — Certivu will not POST events you have not subscribed to.
Registering an endpoint
Section titled “Registering an endpoint”From the dashboard: Webhooks → Add endpoint, enter your URL, check the events you want, and click Save. Copy the secret from the confirmation screen — it is shown once and cannot be retrieved again.
Via API:
POST /v1/webhooksAuthorization: Bearer <session-token>Content-Type: application/json
{ "url": "https://your-app.example.com/certivu", "events": ["record.created", "verify.tamper_detected", "quota.warning"]}Response (201):
{ "webhook_id": "550e8400-e29b-41d4-a716-446655440000", "url": "https://your-app.example.com/certivu", "events": ["record.created", "verify.tamper_detected", "quota.warning"], "status": "active", "failure_count": 0, "created_at": "2026-06-11T10:00:00Z", "secret": "a3f2c8b1e7d4..."}Store the secret somewhere secure — your server needs it to verify every incoming request.
Payload shape
Section titled “Payload shape”Every POST body has the same envelope:
{ "id": "delivery-uuid", "event": "verify.tamper_detected", "timestamp": "2026-06-11T14:23:01Z", "org_id": "org_abc123", "data": { }}id is the delivery ID — use it for idempotency if you process events more than once. data contains event-specific fields.
Verifying the signature
Section titled “Verifying the signature”Every request includes an X-Certivu-Signature header:
X-Certivu-Signature: t=1749654181,v1=3d9f2a...t is the Unix timestamp of delivery. v1 is HMAC-SHA256 of <timestamp>.<raw body> using your endpoint secret.
Always verify this header before processing the payload.
Node.js / Bun
Section titled “Node.js / Bun”import crypto from "node:crypto";
function verifyCertivuWebhook(rawBody, header, secret) { const [tPart, v1Part] = header.split(","); const timestamp = tPart.split("=")[1]; const receivedSig = v1Part.split("=")[1];
const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex");
// Constant-time comparison to prevent timing attacks const sigValid = crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(receivedSig, "hex") );
// Reject requests older than 5 minutes (replay protection) const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (age > 300) return false;
return sigValid;}Python
Section titled “Python”import hashlibimport hmacimport time
def verify_certivu_webhook(raw_body: bytes, header: str, secret: str) -> bool: t_part, v1_part = header.split(",") timestamp = t_part.split("=")[1] received_sig = v1_part.split("=")[1]
expected = hmac.new( secret.encode(), f"{timestamp}.{raw_body.decode()}".encode(), hashlib.sha256, ).hexdigest()
if abs(time.time() - float(timestamp)) > 300: return False # replay protection
return hmac.compare_digest(expected, received_sig)Express example (full handler)
Section titled “Express example (full handler)”import express from "express";import crypto from "node:crypto";
const app = express();
// Use raw body — parsed JSON loses the exact byte representation needed for HMACapp.post("/certivu", express.raw({ type: "application/json" }), (req, res) => { const header = req.headers["x-certivu-signature"]; if (!header || !verifyCertivuWebhook(req.body, header, process.env.CERTIVU_WEBHOOK_SECRET)) { return res.status(401).send("Invalid signature"); }
const event = JSON.parse(req.body);
switch (event.event) { case "verify.tamper_detected": console.log("Tamper detected on record", event.data.record_id); break; case "quota.warning": console.log("Quota at 80% — consider upgrading"); break; // handle other events... }
res.sendStatus(200);});Delivery behaviour
Section titled “Delivery behaviour”- Timeout: Certivu waits up to 10 seconds for a response. Return
2xxquickly — do heavy processing in the background. - No automatic retries: Failed deliveries are logged but not retried automatically. Use the Retry button in the dashboard or
POST /v1/webhooks/:id/deliveries/:did/retry. - Auto-disable: After 5 consecutive failures, the endpoint status changes to
failingand no further events are delivered. Re-enable it from the dashboard or viaPATCH /v1/webhooks/:idwith{ "status": "active" }— this also resets the failure counter. - Delivery log: The last 30 days of deliveries are retained, including the full payload, response code, and latency. View them under the endpoint in the Webhooks dashboard page.
Idempotency
Section titled “Idempotency”Network issues can cause duplicate deliveries — your server may receive the same event more than once. Use the id field (delivery UUID) as an idempotency key:
const alreadyProcessed = await redis.get(`certivu:delivery:${event.id}`);if (alreadyProcessed) return res.sendStatus(200);
await redis.set(`certivu:delivery:${event.id}`, "1", "EX", 86400);// ... process eventTesting locally
Section titled “Testing locally”Use a tunnel tool such as ngrok or Cloudflare Tunnel to expose a local port:
ngrok http 3000# Forwarding: https://abc123.ngrok.io → http://localhost:3000Register https://abc123.ngrok.io/certivu as your webhook URL, then trigger events by signing content or running verifications from the dashboard.
Managing endpoints
Section titled “Managing endpoints”| Action | Dashboard | API |
|---|---|---|
| List endpoints | Webhooks page | GET /v1/webhooks |
| Create endpoint | Add endpoint form | POST /v1/webhooks |
| Update URL / events | Edit button | PATCH /v1/webhooks/:id |
| Disable / re-enable | Toggle button | PATCH /v1/webhooks/:id with { "status": "active" | "disabled" } |
| Delete endpoint | Delete button | DELETE /v1/webhooks/:id |
| View delivery log | Endpoint detail | GET /v1/webhooks/:id/deliveries |
| Retry a delivery | Retry button | POST /v1/webhooks/:id/deliveries/:did/retry |
See the Webhooks API reference for full request/response schemas.