Webhooks Guide

Developer Docs / Webhooks

Real-time Webhooks

Event reference, delivery contract, payload schemas, HMAC-SHA256 signature verification, retries, and production best practices.

12+ Events HMAC Signed Auto Retry <500ms Delivery

How It Works

Webhooks let your Learning Management System (Moodle, Canvas, Docebo, custom portal) react to things that happen inside CertBoost — a user finishing a quiz, an assignment being created, etc. — without having to poll the API.

1. Register

POST a webhook URL via the API, specifying which events you want.

2. Event Fires

CertBoost builds a JSON payload, signs it with HMAC-SHA256, and POSTs it.

3. You React

Your receiver verifies the signature, then does whatever the LMS needs.

Registering a Webhook

Endpoint: POST /api/lms/v1/webhooks — Auth: any API key with ReadWrite permission.

Request body
{
  "url":         "https://your-lms.example.com/hooks/certboost",
  "events":      ["attempt.completed","assignment.created"],
  "description": "Moodle production",
  "secret":      "changeme-shared-secret"
}
  • url (required) — HTTPS endpoint that receives POSTs
  • events (required) — Array of event names, or ["*"] for every event
  • description (optional) — Free-text label shown when listing
  • secret (optional) — Shared secret for HMAC-SHA256 signing

Response 201 returns the created subscription with its new id.

Listing / Deleting / Testing

  • GET /api/lms/v1/webhooks — list yours
  • DELETE /api/lms/v1/webhooks/{id} — remove one (204 on success)
  • POST /api/lms/v1/webhooks/{id}/test — trigger a synthetic webhook.ping event

Supported Events

attempt.completed

A user finishes any quiz, simulation exam, adaptive quiz, or readiness-check diagnostic.

assignment.created

An admin creates an assignment for a user via POST /api/lms/v1/assignments.

webhook.ping

Synthetic event triggered manually by POST /webhooks/{id}/test for connectivity checks.

Future events: user.subscribed, user.unsubscribed, certificate.issued, challenge.finished, streak.broken.

Delivery Contract

CertBoost always POSTs JSON. Every delivery includes these headers and body envelope:

HTTP headers
Content-Type: application/json; charset=utf-8
User-Agent:   CertBoost-Webhook/1.0
X-CertBoost-Delivery:  
X-CertBoost-Event:     
X-CertBoost-Timestamp: 
X-CertBoost-Signature: sha256=   # only if secret was set
Request body envelope
{
  "event":      "",
  "deliveryId": "",
  "occurredAt": "",
  "data":       { ... event-specific payload ... }
}
  • Timeout: 10 seconds per request (configurable in appsettings.json)
  • Retries: Fire-and-forget. One attempt is made; failed deliveries are logged but NOT re-queued in v1.
  • Ordering: Best-effort, not guaranteed. Use occurredAt for ordering.

Payloads

attempt.completed

JSON payload
{
  "event": "attempt.completed",
  "deliveryId": "b4c0e2...d6",
  "occurredAt": "2026-04-18T16:02:11Z",
  "data": {
    "attemptId": "66b0...",
    "userId": "60a1...e8",
    "userEmail": "jane@acme.com",
    "categoryId": 42,
    "categoryName": "Azure Fundamentals",
    "correct": 18,
    "total": 20,
    "percent": 90.0,
    "source": "simulation-exam",
    "takenAt": "2026-04-18T16:01:58Z"
  }
}

Typical LMS reaction: Write the percent into the course gradebook; mark a learning module complete if percent >= passing threshold.

assignment.created

JSON payload
{
  "event": "assignment.created",
  "deliveryId": "4f91...a2",
  "occurredAt": "2026-04-19T23:45:11Z",
  "data": {
    "assignmentId": "aA7...",
    "userId": "60a1...e8",
    "categoryId": 42,
    "categoryName": "Azure Fundamentals",
    "dueAt": "2026-05-01T00:00:00Z",
    "launchUrl": "https://certboost.xyz/Learning/Assignment/aA7...",
    "createdByKey": "<api-key-id-suffix>"
  }
}

Typical LMS reaction: Create a matching assignment in the LMS with the same due date. Deep-link the launchUrl so the student can jump straight into CertBoost.

webhook.ping

JSON payload
{
  "event": "webhook.ping",
  "deliveryId": "ping-7d...",
  "occurredAt": "2026-04-19T23:48:00Z",
  "data": {
    "subscriptionId": "wh_6t...",
    "message": "This is a test delivery."
  }
}

Signature Verification

If you supplied a secret when creating the subscription, every delivery includes:

Header
>X-CertBoost-Signature: sha256=<hex-encoded HMAC>

The signature is HMAC-SHA256 of the raw request body (exact bytes, before JSON parsing) using the shared secret as the key.

Node.js (Express)

javascript
>const crypto = require('crypto');
const express = require('express');
const app = express();

app.use('/hooks/certboost',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig = req.header('X-CertBoost-Signature') || '';
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.CB_SECRET)
      .update(req.body)
      .digest('hex');
    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).send('bad signature');
    }
    const payload = JSON.parse(req.body.toString('utf8'));
    // ... do work ...
    res.sendStatus(200);
  });
app.listen(3000);

Python (Flask)

python
>import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CB_SECRET"].encode()

@app.post("/hooks/certboost")
def certboost():
    raw = request.get_data()
    sig = request.headers.get("X-CertBoost-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    payload = request.get_json(force=True)
    # ... do work ...
    return "", 200

C# / ASP.NET

c#
>[HttpPost("hooks/certboost")]
public async Task Receive()
{
    using var reader = new StreamReader(Request.Body);
    var raw = await reader.ReadToEndAsync();
    var sig = Request.Headers["X-CertBoost-Signature"].ToString();
    using var h = new HMACSHA256(Encoding.UTF8.GetBytes(_secret));
    var expected = "sha256=" +
        Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(raw)))
               .ToLowerInvariant();
    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(sig),
            Encoding.UTF8.GetBytes(expected)))
        return Unauthorized();
    var payload = JsonSerializer.Deserialize<WebhookEnvelope>(raw);
    // ... do work ...
    return Ok();
}

Best Practices

Return 2xx Fast

Reply as soon as the signature is verified; queue heavy processing.

Use signature verification

Treat unsigned payloads as untrusted. Verify with HMAC-SHA256 + timing-safe compare.

Consume idempotently

Guard by deliveryId or attemptId to avoid double-processing.

Subscribe narrowly

List only the events you actually need. Avoid wildcard subscriptions in production.

Log and alert

Log every delivery and alert if >1% fail or if no event arrives for 6 hours.

Protect the secret

Store in a vault. Rotate if leaked. Use separate secrets per environment.

Local Testing

ngrok or cloudflare tunnel -> your local webhook handler.

bash
>ngrok http 3000
# ngrok gives you a public URL

# Register it
curl -X POST https://certboost.xyz/api/lms/v1/webhooks \
     -H "X-CertBoost-Key: " \
     -H "Content-Type: application/json" \
     -d '{"url":"https://abc123.ngrok.io/hooks/certboost",\
           "events":["attempt.completed","assignment.created"],\
           "secret":"local-secret-123"}'

# Trigger test
POST /api/lms/v1/webhooks//test

Your local app will receive a webhook.ping with the same signing pipeline. Verify end-to-end.

Professional Services

Want us to wire it up for you?

We build signed webhook receivers, LMS event pipelines, and retry-safe connectors into Moodle, Canvas, Docebo, and custom platforms — delivered and monitored end-to-end.