Webhooks let your server react to course lifecycle events without polling the status endpoint. Configure one webhook URL per account.
Setup
Webhook URL and secret are configured per account. Contact your account manager with:
- The HTTPS URL to deliver events to
- A
webhook_secret of your choice (32+ random bytes, base64 or hex)
Self-serve webhook configuration is on the roadmap.
Event types
| Event | Fires when |
|---|
course.ready | Course generation completes successfully |
course.failed | Course generation ends in an unrecoverable error |
course.paused_for_review | Generation pauses at the first review checkpoint |
course.module_ready | One module’s video transitions to approved (fires per module) |
course.quota_paused | Generation pauses because the live cost meter crossed the account billing cap |
quota.warning | Account crosses 80% of monthly budget |
quota.exceeded | A course ends early due to budget exhaustion |
Payload shape
All course events share the same envelope: top-level metadata plus a nested data object carrying course fields.
{
"id": "wh_1a2b3c4d5e6f7a8b",
"event": "course.ready",
"created_at": "2026-06-23T10:30:00Z",
"api_version": "v1",
"data": {
"course_id": "20260623_103000_abc123",
"title": "Sales Fundamentals",
"status": "ready",
"created_at": "2026-06-23T10:00:00Z",
"sandbox": false,
"module_count": 6
}
}
data always includes course_id, title, status, created_at, and sandbox. Event-specific additions:
course.ready: adds module_count
course.failed: adds error_message
course.module_ready: adds module_index (0-based) and, when available, video_url (signed, 24-hour TTL)
quota.exceeded: adds module_count and overage_hours
language and external_ref are NOT included in the payload. If you need them, fetch the course with GET /api/v1/courses/{course_id}.
Signing and verification
Every delivery is signed with HMAC-SHA256 using your webhook_secret. Two headers are included:
| Header | Value |
|---|
Mahara-Signature | t=<timestamp>,v1=<hex_hmac> |
X-Mahara-Event | The event name (e.g. course.ready) |
The signed content is <timestamp>.<raw_request_body>. Recompute the HMAC and compare in constant time.
Python verification
import hmac
import hashlib
import time
def verify(request_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = dict(p.split("=") for p in signature_header.split(","))
t = parts["t"]
v1 = parts["v1"]
if abs(time.time() - int(t)) > tolerance_sec:
return False
signed = f"{t}.".encode() + request_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Node.js verification
const crypto = require('crypto');
function verify(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const { t, v1 } = parts;
if (Math.abs(Date.now() / 1000 - parseInt(t)) > toleranceSec) {
return false;
}
const signed = `${t}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Always verify the raw request body, not the parsed JSON. Different JSON serializers produce different byte sequences, which breaks the signature.
Retry behaviour
Deliveries that fail to reach your endpoint are not automatically retried in the current API version. If your endpoint is down, you will miss events.
Mitigations:
- Make your webhook handler highly available (queue + background processor pattern).
- Implement a periodic reconciliation job that calls
GET /api/v1/courses to catch missed events.
- Automatic retries with exponential backoff are on the roadmap.
Idempotency
Your handler must be idempotent. Even with retries off, network reasons may cause duplicate deliveries. Use the top-level id field (e.g. wh_1a2b3c4d5e6f7a8b) as a dedup key in your processing.
Sandbox webhooks
Sandbox runs (with sk_test_ keys) fire webhook events with "sandbox": true in the data envelope. Branch on this field if your downstream effects (e.g. emailing the customer) should not run in sandbox.