Webhooks
Skip polling. Subscribe to state-change events and Verdacert pushes you a signed POST whenever a job moves forward. Stripe- style HMAC signing, durable outbox with ~30-hour retry budget, per-key signing secrets.
Configuring a webhook
Webhooks are configured per API key. The receiver URL + signing secret travel together.
- In /portal/api, open the key, paste your receiver URL, and save. The portal mints a signing secret you'll need to verify deliveries — copy it immediately; it's only shown once.
- (Optional) Pass
webhookUrlon a per-call basis viasubmit()— this overrides the key's stored URL for events on that order only. Useful for multi-tenant routing on top of one key. - Verdacert immediately starts delivering events to that URL for any subsequent state change.
ngrok http 3000 or cloudflared tunnel --url http://localhost:3000 and paste the public URL into the portal. Verdacert pushes events to that URL; ngrok forwards into your local app.Event types
One event fires per status transition. Receivers get exactly one delivery per transition even under concurrent polling (we win-the-update atomically before dispatching).
| Event type | Fires when |
|---|---|
order.created | submit() returned a jobId. |
order.paid | Stripe webhook confirmed the off-session charge. |
order.processing | AI draft pipeline started. |
order.reviewing_draft | R&C only: reviewer picked up the agent draft. |
order.in_review | Human reviewer is verifying. |
order.ready | Certified PDF + JWS available via /result. |
order.delivered | Customer fetched the artifact. |
order.failed | Pipeline failure. Contact us with the request id. |
order.cancelled | Order was cancelled (manual or upstream). |
order.refunded | Full refund processed. |
Payload + headers
Identical shape across every event type.
HTTP headers
POST <your webhook URL>
Content-Type: application/json
x-verdacert-event: order.ready
x-verdacert-event-id: evt_4f8c1b2a3d6e9a01234567ef
x-verdacert-signature: t=1716480000,v1=4d7a5c…| Header | Description |
|---|---|
x-verdacert-event | Event type. Convenient for routing without parsing the body first. |
x-verdacert-event-id | Unique event id. Use it as your dedup key — Verdacert may retry the same event id under failure. |
x-verdacert-signature | t=<unix>,v1=<hex_hmac_sha256>. Verify by HMAC-SHA256(secret, <t>.<rawBody>). |
Body
{
"id": "evt_4f8c1b2a3d6e9a01234567ef",
"type": "order.ready",
"createdAt": "2026-05-22T18:00:00.000Z",
"data": {
"jobId": "ord_8a3c1b…",
"orderNumber": "VC-2026-000142",
"status": "ready",
"apiKeyId": "ak_4d8e…",
"externalEndUserId": "user_42",
"promisedDeliveryAt": "2026-05-23T18:00:00.000Z"
}
}Receiver semantics
- Respond 2xx fast (under 5 seconds). We treat anything outside 200–299 (and 410) as a failure and schedule a retry.
- 2xx = delivered, no retry. 410 Gone= “delete this subscription, stop retrying.” 5xx / timeout = retry per the schedule below.
- Process async.Acknowledge with 200 first, then do the actual work on a background job. Don't hold the connection open while you process.
- Deduplicate on
x-verdacert-event-id. We try not to deliver duplicates, but at-least-once delivery means it's possible — especially around outages.
Verifying the signature
Always verify before acting on the body. Two things matter: (a) constant-time comparison of the HMAC, and (b) timestamp freshness (reject events older than ~5 minutes to prevent replay).
TypeScript / Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
const WEBHOOK_SECRET = process.env.VERDACERT_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 5 * 60;
export function verifyVerdacertSignature(
rawBody: string, // The exact body bytes the request arrived with.
signatureHeader: string, // x-verdacert-signature
): { ok: true; timestamp: number } | { ok: false; reason: string } {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.trim().split("=") as [string, string]),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return { ok: false, reason: "malformed" };
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
return { ok: false, reason: "stale" };
}
const expected = createHmac("sha256", WEBHOOK_SECRET)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return { ok: false, reason: "bad_signature" };
}
return { ok: true, timestamp: t };
}Next.js Route Handler
import { NextResponse, type NextRequest } from "next/server";
import { verifyVerdacertSignature } from "@/lib/verify-verdacert";
export async function POST(req: NextRequest) {
// Read the body as text first — verification uses the exact bytes.
const raw = await req.text();
const sig = req.headers.get("x-verdacert-signature") ?? "";
const v = verifyVerdacertSignature(raw, sig);
if (!v.ok) {
return new NextResponse(`invalid: ${v.reason}`, { status: 400 });
}
const event = JSON.parse(raw) as {
id: string;
type: string;
data: { jobId: string; orderNumber: string; status: string };
};
// Dedup on event.id BEFORE doing work.
// … your queue / handler …
return NextResponse.json({ received: true });
}Python (Flask / FastAPI)
import hmac, hashlib, os, time
WEBHOOK_SECRET = os.environ["VERDACERT_WEBHOOK_SECRET"]
TOLERANCE = 5 * 60
def verify_verdacert_signature(raw_body: bytes, sig_header: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in sig_header.split(","))
t = int(parts.get("t", "0"))
v1 = parts.get("v1", "")
if abs(time.time() - t) > TOLERANCE:
return False
expected = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
f"{t}.{raw_body.decode('utf-8')}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1)Go
package verdacert
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
func VerifySignature(secret, sigHeader string, rawBody []byte) error {
var t int64
var v1 string
for _, p := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
if len(kv) != 2 { continue }
switch kv[0] {
case "t":
t, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
v1 = kv[1]
}
}
if t == 0 || v1 == "" {
return fmt.Errorf("malformed signature header")
}
skew := time.Since(time.Unix(t, 0))
if skew < 0 { skew = -skew }
if skew > tolerance {
return fmt.Errorf("stale event")
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.%s", t, rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(v1)) {
return fmt.Errorf("bad signature")
}
return nil
}Ruby (Rails / Sinatra)
require "openssl"
WEBHOOK_SECRET = ENV.fetch("VERDACERT_WEBHOOK_SECRET")
TOLERANCE_SECONDS = 5 * 60
# raw_body must be the EXACT bytes from request.body.read.
# In Rails: `request.raw_post`. Don't use `params` — it'll re-serialize.
def verify_verdacert_signature(raw_body, sig_header)
parts = sig_header.to_s.split(",").map { |p| p.strip.split("=", 2) }.to_h
t = parts["t"].to_i
v1 = parts["v1"].to_s
return false if t.zero? || v1.empty?
return false if (Time.now.to_i - t).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest("SHA256", WEBHOOK_SECRET, "#{t}.#{raw_body}")
# secure_compare is constant-time.
OpenSSL.fixed_length_secure_compare(expected, v1)
rescue ArgumentError
false # length mismatch ⇒ fixed_length_secure_compare raises
end
# Rails controller
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def verdacert
raw = request.raw_post
sig = request.headers["x-verdacert-signature"]
return head :bad_request unless verify_verdacert_signature(raw, sig)
event = JSON.parse(raw)
# Dedup on event["id"] BEFORE enqueueing work.
head :ok
end
endPHP (Laravel / vanilla)
<?php
// Always read the raw body for HMAC. Frameworks that pre-parse JSON
// reorder keys and break the signature.
$secret = getenv('VERDACERT_WEBHOOK_SECRET');
$tolerance = 5 * 60;
function verifyVerdacertSignature(string $rawBody, string $sigHeader, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $sigHeader) as $p) {
[$k, $v] = array_pad(explode('=', trim($p), 2), 2, null);
if ($k !== null) $parts[$k] = $v;
}
$t = (int)($parts['t'] ?? 0);
$v1 = (string)($parts['v1'] ?? '');
if ($t === 0 || $v1 === '') return false;
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
// hash_equals is constant-time.
return hash_equals($expected, $v1);
}
// Laravel controller
class VerdacertWebhookController extends Controller {
public function __invoke(\Illuminate\Http\Request $request) {
$raw = $request->getContent();
$sig = $request->header('x-verdacert-signature') ?? '';
if (!verifyVerdacertSignature($raw, $sig, env('VERDACERT_WEBHOOK_SECRET'))) {
return response('bad signature', 400);
}
$event = json_decode($raw, true);
// Dedup on $event['id'] BEFORE enqueueing work.
return response()->json(['received' => true]);
}
}crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hmac.Equal (Go), OpenSSL.fixed_length_secure_compare (Ruby), hash_equals(PHP), or your platform's equivalent.Retry schedule
Each event is persisted to a durable outbox before the first attempt. The first attempt fires inline; failures get scheduled via the cron with this back-off (per attempt N, 0-indexed):
| Attempt | Delay after previous failure |
|---|---|
| 1 | Inline (immediate) |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours (final) |
Total budget is ~30 hours over 8 attempts. After the 8th failure the delivery row is marked abandonedand surfaced in the agent portal's “recent deliveries” panel. Recover by replaying from the portal or by polling /status and /result.
Operational tips
Patterns we've seen work well in production.
- One receiver, many events. The same URL handles every event type — route on
x-verdacert-event. - Dedup on event id. Use a unique index on event id in your DB, or an idempotent processor. Cheap.
- Replay from the portalwhen a receiver was down — recent deliveries panel has a “redeliver” button. We re-sign on replay; your verification code stays identical.
- Don't reach back into our API on every event.
data.statusanddata.orderNumberare sufficient for the common cases (notify your end-user, kick off your post-processing). Hit/resultonly onorder.ready. - Rotate the signing secretby regenerating it in the portal. There's no overlap window — sequence the rollout to swap both sides in the same window.
