Skip to content

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.

  1. You register an HTTPS endpoint from the dashboard or via POST /v1/webhooks.
  2. Certivu stores the endpoint URL and a signing secret (shown once at creation).
  3. When an event fires, Certivu delivers a signed POST to your URL within the same request cycle — no queue delay.
  4. Your server verifies the signature, processes the event, and returns any 2xx status.
  5. Non-2xx responses are recorded as failures. After 5 consecutive failures the endpoint is automatically disabled.
EventFired when
record.createdA provenance record is signed and stored
verify.attemptedAny verification runs against your records
verify.tamper_detectedA verification returns tampered: true
quota.warningSigning usage crosses 80% of your plan limit
quota.limitPlan limit reached — signing is now blocked (402)
generator.revokedOne of your generators is revoked

Subscribe only to the events you need — Certivu will not POST events you have not subscribed to.

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/webhooks
Authorization: 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.

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.

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.

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;
}
import hashlib
import hmac
import 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)
import express from "express";
import crypto from "node:crypto";
const app = express();
// Use raw body — parsed JSON loses the exact byte representation needed for HMAC
app.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);
});
  • Timeout: Certivu waits up to 10 seconds for a response. Return 2xx quickly — 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 failing and no further events are delivered. Re-enable it from the dashboard or via PATCH /v1/webhooks/:id with { "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.

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 event

Use a tunnel tool such as ngrok or Cloudflare Tunnel to expose a local port:

Terminal window
ngrok http 3000
# Forwarding: https://abc123.ngrok.io → http://localhost:3000

Register https://abc123.ngrok.io/certivu as your webhook URL, then trigger events by signing content or running verifications from the dashboard.

ActionDashboardAPI
List endpointsWebhooks pageGET /v1/webhooks
Create endpointAdd endpoint formPOST /v1/webhooks
Update URL / eventsEdit buttonPATCH /v1/webhooks/:id
Disable / re-enableToggle buttonPATCH /v1/webhooks/:id with { "status": "active" | "disabled" }
Delete endpointDelete buttonDELETE /v1/webhooks/:id
View delivery logEndpoint detailGET /v1/webhooks/:id/deliveries
Retry a deliveryRetry buttonPOST /v1/webhooks/:id/deliveries/:did/retry

See the Webhooks API reference for full request/response schemas.