# LearnUpon Webhooks

LearnUpon (the learning management system) sends a JSON `POST` to your endpoint when something happens on
your portal: a user is created, a learner is enrolled or completes a course, a module is completed, a
badge is awarded, a live-learning session changes. Each request is signed with HMAC-SHA256 in the
`X-Webhook-Signature` header.

Only Webhooks v2.0 are supported. Ask LearnUpon support to turn them on.

Version 1 is a separate, older scheme with a different verification process. This skill covers v2.0 only.

## When to Use This Skill

* How do I receive LearnUpon webhooks?
* How do I verify the LearnUpon `X-Webhook-Signature` header?
* How do I handle `course_completion`, `course_enrollment` or `user_created` events?
* Why does my LearnUpon webhook signature verification fail (401)?
* How do I read the `data` array and `X-Webhook-Type` header?

## Verification (core)

```javascript
const crypto = require('crypto');

// rawBody: Buffer of the request body exactly as received (express.raw({ type: '*/*' })).
function verifyLearnuponSignature(rawBody, signature, secret = process.env.LEARNUPON_WEBHOOK_SECRET) {
  if (!secret || !Buffer.isBuffer(rawBody)) return false;
  // Validate the format first: exactly 64 hex characters (case-insensitive).
  if (typeof signature !== 'string' || !/^[0-9a-fA-F]{64}$/.test(signature)) return false;
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(rawBody).digest();
  const received = Buffer.from(signature.toLowerCase(), 'hex'); // always 32 bytes after validation
  return crypto.timingSafeEqual(expected, received);
}

```

* Header: `X-Webhook-Signature: <64 hex characters>`. The whole value is the digest: no prefix, no list.
* Signed content: the **raw request body bytes only**. No timestamp, id or other header is signed
  (`X-LearnUpon-TimeStamp` is sent but not signed), so there is no replay window to check.
* Key: the secret you set in the portal's webhook configuration, used as its UTF-8 bytes
  (not hex- or base64-decoded). Env var: `LEARNUPON_WEBHOOK_SECRET`.
* Python: `hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()`, validate the header with
  `re.fullmatch(r"[0-9a-fA-F]{64}", sig)`, then `hmac.compare_digest` on **bytes**.
* Never `JSON.parse`/`JSON.stringify` before verifying. Read the body raw, verify, then parse.
* Missing, empty or malformed header, or a mismatch: return `401`.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/learnupon-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/learnupon-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/learnupon-webhooks/examples/fastapi/).

## Event Types

The body is `{"data": [...]}` with up to 10 events of one type. The `X-Webhook-Type` header names the
type; loop over `data`. Dedupe on the `X-Webhook-ID` header. The webhook must accept `POST`.

| Event (`X-Webhook-Type`) | Sent When |
| --- | --- |
| `user_created`, `user_updated` | A user account is created or updated |
| `course_cloning_complete` | An admin finishes cloning a course |
| `course_completion` | A learner completes a course |
| `course_updated` | A course is updated |
| `module_complete` | A learner completes a module within a course |
| `purchase_completion` | A customer completes a purchase of courses on the portal |
| `badge_awarded`, `badge_revoked` | A badge is awarded to or removed from a learner |
| `course_enrollment`, `course_unenrollment` | A learner is enrolled on or unenrolled from a course |
| `learning_path_enrollment`, `learning_path_updated`, `learning_path_completion` | A learning path enrollment, update or completion |
| `event_session_creation`, `event_session_updated`, `event_session_canceled` | A live-learning event session is created, updated or cancelled |
| `learning_journey_enrollment`, `learning_journey_completion`, `learning_journey_blocked`, `learning_journey_deleted`, `learning_journey_archived` | A learning journey enrollment, completion, blocked enrollment, deletion or archive |

## Delivery

Answer `2xx`, ideally within 2 seconds. No response within 7 seconds counts as a failure. Failed
webhooks are resent for 72 hours (at 10 minutes, 3 hours, 12 hours, 24 hours and 48 hours), and
LearnUpon retains webhooks for 45 days. Acknowledge fast and process asynchronously.

## Environment Variables

```bash
LEARNUPON_WEBHOOK_SECRET=your-webhook-secret   # the secret set in the portal's webhook configuration

```

## Local Development

LearnUpon can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 learnupon --path /webhooks/learnupon

```

Use the printed URL as the webhook URL in your LearnUpon portal.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/learnupon-webhooks/references/overview.md) - What LearnUpon webhooks are, events and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/learnupon-webhooks/references/setup.md) - Enable webhooks and configure the endpoint and secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/learnupon-webhooks/references/verification.md) - Signature verification details, published test vector, debugging
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/learnupon-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests