API documentation

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.

  1. 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.
  2. (Optional) Pass webhookUrl on a per-call basis via submit()— this overrides the key's stored URL for events on that order only. Useful for multi-tenant routing on top of one key.
  3. Verdacert immediately starts delivering events to that URL for any subsequent state change.
Local development with ngrok / Cloudflare Tunnel
Webhooks need a public URL. For local dev, expose your dev server with 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 typeFires when
order.createdsubmit() returned a jobId.
order.paidStripe webhook confirmed the off-session charge.
order.processingAI draft pipeline started.
order.reviewing_draftR&C only: reviewer picked up the agent draft.
order.in_reviewHuman reviewer is verifying.
order.readyCertified PDF + JWS available via /result.
order.deliveredCustomer fetched the artifact.
order.failedPipeline failure. Contact us with the request id.
order.cancelledOrder was cancelled (manual or upstream).
order.refundedFull 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…
HeaderDescription
x-verdacert-eventEvent type. Convenient for routing without parsing the body first.
x-verdacert-event-idUnique event id. Use it as your dedup key — Verdacert may retry the same event id under failure.
x-verdacert-signaturet=<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
end

PHP (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]);
    }
}
Always compare in constant time
A naive string equality on the HMAC leaks information through timing side-channels. Use 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):

AttemptDelay after previous failure
1Inline (immediate)
230 seconds
32 minutes
410 minutes
51 hour
66 hours
712 hours
824 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.

Signature timestamp is stable across retries
We sign the body once at first-attempt time and reuse the signature across retries. That keeps signature verification deterministic even when a delivery sits in the outbox for hours. The 5-minute replay window applies to fresh events, not retries — receivers handling retries should base freshness on the event id, not the timestamp.

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.status and data.orderNumber are sufficient for the common cases (notify your end-user, kick off your post-processing). Hit /result only on order.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.

Where to next

Get instant quotePricing