> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myustadia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive course lifecycle events instead of polling; HMAC-SHA256 signed deliveries

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.

```json theme={null}
{
  "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

```python theme={null}
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

```javascript theme={null}
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));
}
```

<Warning>
  Always verify the raw request body, not the parsed JSON. Different JSON serializers produce different byte sequences, which breaks the signature.
</Warning>

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