Webhook-Signaturen verifizieren
Certifylize Webhooks sind signiert (HMAC-SHA256). Prüfe die Signatur mit dem Endpoint-Secret und dem RAW Request Body (nicht geparstes JSON!).
Headers
X-Certifylize-Signature: sha256=<hex> X-Certifylize-Delivery-Id: <deliveryId> X-Certifylize-Event-Type: <eventType> Content-Type: application/json
So funktioniert die Signatur
- Header: X-Certifylize-Signature: sha256=<hex>
- Basis: HMAC-SHA256(secret, raw_json_payload)
- Vergleich: timing-safe (crypto.timingSafeEqual)
- HTTP 2xx = Erfolg, alles andere wird als FAILED geloggt und kann retried werden
Best Practices
- RAW Body verwenden (Buffer/String exakt wie empfangen).
- Idempotenz: Delivery-Id speichern und doppelte ignorieren.
- Secrets pro Endpoint (nicht ein globales Secret für alles).
- Bei Fehlern: gib sinnvolle 4xx/5xx Antworten zurück (wird im Delivery gespeichert).
Events
Webhooks liefern ein Event-Type-Header + JSON Payload. Die folgenden Events sind typische Beispiele. Passe die Liste an deine Nutzung an. Außerdem: Intern heißen Events z. B. EDU_CREDENTIAL_ISSUED; nach außen (Header/Payload) nutzt Certifylize die Public Strings wie edu.credential.issued.
| Event Type | Beschreibung |
|---|---|
| edu.credential.issued | Ein Bildungszertifikat wurde ausgestellt. |
| edu.credential.updated | Ein Bildungszertifikat wurde aktualisiert. |
| edu.credential.revoked | Ein Bildungszertifikat wurde widerrufen. |
| edu.api_key.created | Ein API Key wurde erstellt. |
| edu.api_key.revoked | Ein API Key wurde widerrufen. |
Payload Beispiele
Die Payload ist JSON. Nutze den RAW Body zur Signaturprüfung; erst danach JSON parsen.
{
"type": "edu.credential.issued",
"createdAt": "2026-01-15T12:34:56.000Z",
"data": {
"credentialId": "cmkd3owfi0002rzjp0yyo2jfr",
"verifyId": "009f0e320cd9ea5f1173b42e8c2c4dd6db6632e70bbb65b8",
"status": "ACTIVE",
"recipientEmail": "max@example.com"
}
}Retries & Timeouts
- Nur HTTP 2xx gilt als Erfolg.
- Nicht-2xx wird als FAILED gespeichert und automatisch mit Backoff erneut versucht (sofern aktiv).
- Manuelles Retry (UI) kann zusätzlich ausgelöst werden.
- Implementiere einen schnellen Receiver: validieren, enqueue, sofort 2xx zurückgeben.
- Wenn du lange Logik brauchst: in Queue/Job verschieben (z. B. BullMQ, SQS, etc.).
Idempotenz
Nutze X-Certifylize-Delivery-Id als Idempotency-Key. Speichere deliveryId (z. B. in DB/Cache) und verarbeite ein Event nicht doppelt.
Test-Flow in Certifylize
Im Org-Webhooks UI kannst du einen Test-Webhook auslösen. Dabei erstellt Certifylize ein Event + Delivery und versucht sofort zuzustellen. Die Details findest du in den 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
Diese Variablen steuern Timeout/Hardening. Wenn nicht gesetzt, gelten die Defaults (siehe Beispiele).
# 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: beispiel.test" \
--data "$BODY"Hinweis
Die Express-Datei ist für externe Systeme/Kunden. In Certifylize selbst nutzt du Next.js Route Handlers (wie unten).
// 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 });
}