Verify webhook signatures

Certifylize webhooks are signed (HMAC-SHA256). Verify the signature using the endpoint secret and the RAW request body (not parsed JSON!).

Headers

Headers
X-Certifylize-Signature: sha256=<hex>
X-Certifylize-Delivery-Id: <deliveryId>
X-Certifylize-Event-Type: <eventType>
Content-Type: application/json

How the signature works

  • Header: X-Certifylize-Signature: sha256=<hex>
  • Base: HMAC-SHA256(secret, raw_json_payload)
  • Compare: timing-safe (crypto.timingSafeEqual)
  • HTTP 2xx = success, everything else is logged as FAILED and can be retried

Best practices

  • Use the RAW body (Buffer/String exactly as received).
  • Idempotency: store deliveryId and ignore duplicates.
  • Use per-endpoint secrets (not one global secret).
  • Return meaningful 4xx/5xx responses (stored in delivery logs).

Events

Webhooks include an event-type header + JSON payload. The following events are typical examples. Adjust the list to your use case. Note: Internally events are named like EDU_CREDENTIAL_ISSUED; externally (headers/payload) Certifylize uses public strings like edu.credential.issued.

Event TypeDescription
edu.credential.issuedAn edu credential was issued.
edu.credential.updatedAn edu credential was updated.
edu.credential.revokedAn edu credential was revoked.
edu.api_key.createdAn API key was created.
edu.api_key.revokedAn API key was revoked.

Payload examples

Payloads are JSON. Use RAW body for signature verification; only parse JSON after the signature is valid.

Payload (example)
{
  "type": "edu.credential.issued",
  "createdAt": "2026-01-15T12:34:56.000Z",
  "data": {
    "credentialId": "cmkd3owfi0002rzjp0yyo2jfr",
    "verifyId": "009f0e320cd9ea5f1173b42e8c2c4dd6db6632e70bbb65b8",
    "status": "ACTIVE",
    "recipientEmail": "max@example.com"
  }
}

Retries & timeouts

  • Only HTTP 2xx counts as success.
  • Non-2xx is stored as FAILED and may be automatically retried with backoff (if enabled).
  • Manual retry (UI) can be triggered as well.
  • Build a fast receiver: verify, enqueue, return 2xx quickly.
  • If you need long processing: move work to a queue/job system.

Idempotency

Use X-Certifylize-Delivery-Id as the idempotency key. Store deliveryId (DB/cache) and ignore duplicates.

Test flow in Certifylize

In the org webhooks UI you can send a test webhook. Certifylize creates an event + delivery and attempts delivery immediately. Inspect results in Deliveries.

Test endpoint (internal)
POST /api/edu/orgs/:orgId/webhooks/:endpointId/test
Authorization: Bearer <API_KEY_WITH_admin:write>
Content-Type: application/json
Test with curl (local)
ORG_ID="cmkxxxxxxxxxxxxxxxxxxxx"
ENDPOINT_ID="cmkxxxxxxxxxxxxxxxxxxxx"
API_KEY="edu_xxxxxxxxxxxxxxxxxxxxx"

curl -i -X POST "http://localhost:3000/api/edu/orgs/$ORG_ID/webhooks/$ENDPOINT_ID/test" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json"
Example response
{
  "ok": true,
  "eventId": "cmk...",
  "deliveryId": "cmk...",
  "result": {
    "ok": true,
    "status": 200
  }
}

Environment variables

These variables control timeout/hardening. If not set, defaults apply (see examples).

.env (example)
# Webhook sender timeout (ms)
# Default: 8000
EDU_WEBHOOK_TIMEOUT_MS=8000

# Cron runner hard timeout (ms)
# Default: 25000
CRON_HARD_TIMEOUT_MS=25000
Test with curl (example)
# Example curl (replace SECRET + URL)
# NOTE: This example uses openssl to compute the HMAC over the raw JSON body.
URL="https://your-domain.com/webhooks/certifylize"
SECRET="YOUR_ENDPOINT_SECRET"

BODY='{"hello":"world"}'

SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -i -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "X-Certifylize-Signature: sha256=$SIG" \
  -H "X-Certifylize-Delivery-Id: dlv_test_123" \
  -H "X-Certifylize-Event-Type: example.test" \
  --data "$BODY"

Note

The Express file is for external customer systems. Inside Certifylize you should use Next.js route handlers (see below).

Example: Node.js + Express (receiver)
// server.js (Express)
const express = require("express");
const crypto = require("crypto");

const app = express();

// ✅ Capture RAW body as Buffer (important!)
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf; // Buffer
    },
  })
);

function computeHmac(secret, rawBodyBuffer) {
  return crypto.createHmac("sha256", secret).update(rawBodyBuffer).digest("hex");
}

function timingSafeEqualHex(a, b) {
  const aa = Buffer.from(a, "hex");
  const bb = Buffer.from(b, "hex");
  if (aa.length !== bb.length) return false;
  return crypto.timingSafeEqual(aa, bb);
}

app.post("/webhooks/certifylize", (req, res) => {
  const secret = process.env.CERTIFYLIZE_WEBHOOK_SECRET;
  if (!secret) return res.status(500).json({ ok: false, error: "Missing secret" });

  const header = req.get("X-Certifylize-Signature") || "";
  const provided = header.startsWith("sha256=") ? header.slice("sha256=".length) : "";

  if (!provided || !req.rawBody) {
    return res.status(400).json({ ok: false, error: "Missing signature or raw body" });
  }

  const expected = computeHmac(secret, req.rawBody);
  const valid = timingSafeEqualHex(provided, expected);
  if (!valid) return res.status(401).json({ ok: false, error: "Invalid signature" });

  const deliveryId = req.get("X-Certifylize-Delivery-Id");
  const eventType = req.get("X-Certifylize-Event-Type");

  // ✅ Now req.body is trustworthy
  return res.json({ ok: true, deliveryId, eventType });
});

app.listen(3005, () => console.log("Listening on http://localhost:3005"));
Example: Next.js Route Handler (App Router)
// app/api/webhooks/certifylize/route.ts (Next.js App Router)
import crypto from "crypto";
import { NextResponse } from "next/server";

export const runtime = "nodejs";

function hmacSha256(secret: string, raw: string) {
  return crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
}

function timingSafeEqualHex(a: string, b: string) {
  const aa = Buffer.from(a, "hex");
  const bb = Buffer.from(b, "hex");
  if (aa.length !== bb.length) return false;
  return crypto.timingSafeEqual(aa, bb);
}

export async function POST(req: Request) {
  const secret = process.env.CERTIFYLIZE_WEBHOOK_SECRET;
  if (!secret) return NextResponse.json({ ok: false, error: "Missing secret" }, { status: 500 });

  // ✅ RAW body (important)
  const raw = await req.text();

  const sigHeader = req.headers.get("x-certifylize-signature") || "";
  const provided = sigHeader.startsWith("sha256=") ? sigHeader.slice("sha256=".length) : "";
  if (!provided) return NextResponse.json({ ok: false, error: "Missing signature" }, { status: 400 });

  const expected = hmacSha256(secret, raw);
  const valid = timingSafeEqualHex(provided, expected);
  if (!valid) return NextResponse.json({ ok: false, error: "Invalid signature" }, { status: 401 });

  const deliveryId = req.headers.get("x-certifylize-delivery-id");
  const eventType = req.headers.get("x-certifylize-event-type");

  let payload: any = null;
  try { payload = JSON.parse(raw); } catch {}

  return NextResponse.json({ ok: true, deliveryId, eventType, payload });
}