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
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 Type | Description |
|---|---|
| edu.credential.issued | An edu credential was issued. |
| edu.credential.updated | An edu credential was updated. |
| edu.credential.revoked | An edu credential was revoked. |
| edu.api_key.created | An API key was created. |
| edu.api_key.revoked | An API key was revoked. |
Payload examples
Payloads are JSON. Use RAW body for signature verification; only parse JSON after the signature is valid.
{
"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.
POST /api/edu/orgs/:orgId/webhooks/:endpointId/test Authorization: Bearer <API_KEY_WITH_admin:write> Content-Type: application/json
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"
{
"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).
# Webhook sender timeout (ms) # Default: 8000 EDU_WEBHOOK_TIMEOUT_MS=8000 # Cron runner hard timeout (ms) # Default: 25000 CRON_HARD_TIMEOUT_MS=25000
# 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).
// 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"));// 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 });
}