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

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 TypeBeschreibung
edu.credential.issuedEin Bildungszertifikat wurde ausgestellt.
edu.credential.updatedEin Bildungszertifikat wurde aktualisiert.
edu.credential.revokedEin Bildungszertifikat wurde widerrufen.
edu.api_key.createdEin API Key wurde erstellt.
edu.api_key.revokedEin API Key wurde widerrufen.

Payload Beispiele

Die Payload ist JSON. Nutze den RAW Body zur Signaturprüfung; erst danach JSON parsen.

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

  • 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.

Test Endpoint (intern)
POST /api/edu/orgs/:orgId/webhooks/:endpointId/test
Authorization: Bearer <API_KEY_WITH_admin:write>
Content-Type: application/json
Test mit curl (lokal)
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"
Beispiel-Response
{
  "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).

.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 mit curl (Beispiel)
# 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).

Beispiel: 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"));
Beispiel: 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 });
}