# Guide to Lessonly Webhooks: Features and Best Practices

Lessonly webhooks tell your systems when someone finishes training. When a learner completes a lesson, Lessonly can call a URL you choose, so you can record the completion in an HRIS, unlock access in another tool, or keep a compliance report current without polling the API. Lessonly is now part of Seismic and sold as Seismic Learning, and lessonly.com redirects to Seismic's site, but the API reference at docs.lessonly.com still uses the Lessonly name, and so does Hookdeck's source type.

This guide covers how the lesson completion webhook is configured and authenticated, what Lessonly's documentation does and does not tell you, and how to build a receiver that copes with the gaps.

## What are Lessonly webhooks?

Lessonly's API reference describes a single webhook: a callback sent upon lesson completion. There is no dashboard list of subscriptions and no choice of events. Each company has one callback URL, set through the company settings API, and every request to it is a lesson completion.

Lessonly does not sign the callback. The company settings include an optional `lesson_completed_callback_authorization` value, which Lessonly describes as "the optional value of an Authorization: VALUE header to be sent with webhooks". Lessonly sends the string as it is, without adding `Bearer ` or any other scheme. Your receiver compares the `Authorization` header with that string.

## Lessonly webhook features

| Feature | Details |
| --- | --- |
| Configuration | `PUT https://api.lessonly.com/api/v1/company_settings` |
| Events | Lesson completion only |
| Callback URL | `lesson_completed_callback_url`, one per company |
| Authentication | `lesson_completed_callback_authorization`, sent verbatim as the `Authorization` header |
| Signature and timestamp | None |
| Payload and method | Not documented in the API reference |
| Retries and timeout | Not documented |
| Source IPs | Not published |

The API reference points to separate webhook documentation for the payload, and that documentation is not published alongside it. The docs also carry a notice that Seismic is limiting Lessonly Public API write endpoints for users, groups, lessons and paths as customers move to the upgraded platform. The company settings endpoint is not among those listed.

## The payload

Because the body is not documented, capture a real delivery before you write code that depends on field names. Point the callback URL at an inspection tool, complete a lesson as a test learner, and look at the method, headers and body you receive.

> **Inspect Lessonly webhooks as they arrive.** Set `lesson_completed_callback_url` to a [Hookdeck Inspect](https://inspect.hookdeck.com) URL and complete a lesson to see exactly what Lessonly sends, with no account or setup required.

## Setting up Lessonly webhooks

You need a Lessonly API key. Sign in, open the company settings page and click **API & Webhook**. If you do not see the API key link, Lessonly says the feature may need to be activated for your company. API requests use HTTP Basic auth, with your Lessonly subdomain as the username and the API key as the password.

Decide on the full `Authorization` value first. Generate a long random token, for example with `openssl rand -hex 32`, and add a scheme yourself if you want one, such as `Bearer <token>`. Then set both fields:

```bash
curl -X PUT https://api.lessonly.com/api/v1/company_settings \
  -H "Content-Type: application/json" \
  -u "SUBDOMAIN:API_KEY" \
  -d '{
    "lesson_completed_callback_url": "https://example.com/webhooks/lessonly",
    "lesson_completed_callback_authorization": "Bearer YOUR_LONG_RANDOM_TOKEN"
  }'

```

A successful call returns the stored values, so you can confirm both were saved.

To route through Hookdeck, create a Hookdeck source with the **Lessonly** type and use its URL as `lesson_completed_callback_url`. The source has one field, **Authorization Header**: paste the `lesson_completed_callback_authorization` value exactly as you set it, including any `Bearer ` or `Basic ` prefix. Hookdeck compares the whole value. The source accepts `POST`. Lessonly's reference does not state the method, so check your first delivery in Hookdeck to confirm it arrived.

## Securing Lessonly webhooks

Compare the whole `Authorization` header with the value you set, in constant time. Do not add or strip a scheme in code: whatever you put in `lesson_completed_callback_authorization` is the whole credential.

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

// Exactly the lesson_completed_callback_authorization value.
const EXPECTED = process.env.LESSONLY_AUTHORIZATION;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/lessonly', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get('authorization');
  if (!EXPECTED || typeof received !== 'string' || !safeEqual(received, EXPECTED)) {
    return res.sendStatus(401);
  }

  // The body format is not documented: keep the raw text if it isn't JSON.
  const raw = req.body.toString('utf8');
  let completion = raw;
  try {
    completion = JSON.parse(raw);
  } catch {}

  // Store the completion, return 200, and read fields defensively in a worker.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

EXPECTED = os.environ.get("LESSONLY_AUTHORIZATION", "")

def verify_lessonly_request(headers) -> bool:
    if not EXPECTED:
        return False  # never accept when unconfigured
    received = headers.get("authorization")
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), EXPECTED.encode("utf-8"))

```

A static `Authorization` value authenticates the sender, not the body. It cannot detect a modified request, and with no signature or timestamp a captured request can be replayed. Use an HTTPS callback URL and a long random value, and set a new value with the same `PUT` if the old one might have leaked. The field is optional, so if it was never set Lessonly sends no `Authorization` header and there is nothing to check.

> **Make Lessonly webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Authorization value on every lesson completion, keeps each request with its full body so you can see the undocumented payload, and retries delivery to your service on your schedule.

## Lessonly webhook limitations and pain points

### The payload is undocumented

**The Problem:** You have to write a handler for a body you have never seen.

**Why It Happens:** The API reference documents the settings that configure the webhook, but points elsewhere for the payload, and that page is not published with it.

**Workarounds:**

* Capture real deliveries first, then write the handler against what you saw.
* Read fields defensively and reject nothing just because a field you did not expect is present.

**How Hookdeck Can Help:** Hookdeck stores every request with its headers and body, so you can see each completion exactly as Lessonly sent it and replay it against your handler while you develop.

### No documented retries

**The Problem:** If your endpoint is down when a learner finishes a lesson, you cannot tell whether that completion will ever arrive.

**Why It Happens:** Lessonly does not document retries, a timeout, or what happens to a failed callback.

**Workarounds:**

* Keep the endpoint simple and highly available, and acknowledge as soon as the request is stored.
* Reconcile completions against Lessonly's API on a schedule for anything that must be complete, such as compliance training.

**How Hookdeck Can Help:** Hookdeck accepts each callback and stores it before your service sees it, then delivers with [retries](/docs/retries) you control, so your own downtime does not lose completions.

### One callback URL per company

**The Problem:** Two systems that both need completions cannot each register their own URL.

**Why It Happens:** `lesson_completed_callback_url` is a single company setting.

**Workarounds:**

* Point the callback at a service you own and fan out from there.

**How Hookdeck Can Help:** A Hookdeck source can have several [connections](/docs/connections), so one Lessonly callback can be delivered to every system that needs it.

## Best practices

### Store the exact Authorization value

Keep the value exactly as you sent it in `lesson_completed_callback_authorization`, and compare it whole. If you included `Bearer `, it is part of the value.

### Deduplicate on the learner and the lesson

There is no documented event ID. Once you have seen a real payload, build an idempotency key from the learner, the lesson and the completion time, so a repeated request does not record a completion twice. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Verify the header, store the request, return `200`, and update downstream systems afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Lessonly, now Seismic Learning, sends one webhook, on lesson completion, to a single company callback URL, authenticated only by the `Authorization` value you set in the company settings. Compare that whole value in constant time over HTTPS, capture real deliveries before relying on the undocumented payload, and make the handler idempotent because retries are not documented either way.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, keeps every completion with its full body, and retries and replays delivery to your service, so the gaps in Lessonly's documentation do not turn into gaps in your training records.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Lessonly webhooks reliably in minutes.