# Guide to Coassemble Webhooks: Features and Best Practices

Coassemble webhooks tell your systems when a course is created, when a learner starts or finishes a course, when a learner passes or fails a quiz, and when a Classic course migration makes progress. If you embed Coassemble training in your product or sync completions to an LMS, HRIS or CRM, webhooks are how those learner records reach you without polling the tracking API.

This guide covers the events Coassemble sends, how deliveries are signed, how test deliveries and retries behave, and the practices that keep a Coassemble receiver reliable.

## What are Coassemble webhooks?

Coassemble webhooks are server-to-server `POST` requests with a JSON body, sent to an HTTPS endpoint you register. They're available on the Build plan and above. If you embed Coassemble in an iframe and only need events in the browser, the Course Player embeddable has its own browser-side events, which this guide doesn't cover.

Each endpoint gets its own signing secret when you create it. Coassemble signs every delivery with HMAC-SHA256 over the `X-Coassemble-Timestamp` value, a period and the raw body, and sends the result as `X-Coassemble-Signature: sha256=<hex>`.

## Coassemble webhook features

| Feature | Details |
| --- | --- |
| Plan | Build and above |
| Configuration | **Developer > Webhooks** in Coassemble authoring, or the Webhooks API |
| Endpoint URL | HTTPS only |
| Signature header | `X-Coassemble-Signature`, in the form `sha256=<hex>` |
| Timestamp header | `X-Coassemble-Timestamp`, Unix seconds |
| Signed content | `<timestamp>.<raw body>` |
| Secret | One per endpoint, returned once at creation |
| Event field | `type` in the body, also sent in `X-Coassemble-Event` |
| Delivery ID | `X-Coassemble-Delivery`, a UUID that stays the same across retries |
| Test deliveries | `testMode: true` and `data.test: true`, signed like live ones |
| Success response | Any `2xx` within 10 seconds |
| Retries | Up to 6 attempts over about 8.5 hours |
| Replay | Manual, after automatic retries stop |

## Common events

| Event | Fires when |
| --- | --- |
| `course.created` | A new course is created |
| `course.commenced` | A learner first commences a course tracking record |
| `course.completed` | A learner first completes a course tracking record |
| `quiz.completed` | A learner passes a standalone Screen Player quiz |
| `quiz.failed` | A learner fails a standalone Screen Player quiz |
| `migration.progress` | One course in a Classic migration batch finishes (migrated, blocked or failed) |
| `migration.completed` | A Classic migration batch reaches `completed` or `completed_with_errors` |

`course.commenced` and `course.completed` fire on the first commencement and first completion of a tracking record, not on every revisit. When the tracking was taken inside a collection, `data.collection` holds `{ id, key }`. The migration events fire only for batch migrations of two or more courses, which return HTTP `202`. A one-course migration returns `200` and sends neither.

Every body has the same envelope: `id`, `type`, `occurredAt`, `workspaceId`, `testMode` and `data`. Here is Coassemble's `course.completed` example:

```json
{
  "id": "17fd9df8-c77a-4b7d-a281-267b74f8cbf3",
  "type": "course.completed",
  "occurredAt": "2026-02-22T10:15:30.000Z",
  "workspaceId": 1234,
  "testMode": false,
  "data": {
    "course": {
      "id": 4321,
      "title": "Security Basics",
      "key": "security-basics",
      "clientIdentifier": "course_abc"
    },
    "collection": { "id": 42, "key": "onboarding-path" },
    "tracking": {
      "id": 8888,
      "identifier": "user_123",
      "email": "user@example.com",
      "commenced": "2026-02-22T10:01:00.000Z",
      "completed": "2026-02-22T10:15:30.000Z",
      "totalTime": 870
    }
  }
}

```

`tracking.identifier` is the learner identifier you passed when embedding, which is usually the easiest key to join on in your own system.

## Setting up Coassemble webhooks

1. Build an HTTPS endpoint that accepts JSON `POST` requests and returns a `2xx` quickly.
2. In Coassemble authoring, go to **Developer > Webhooks**.
3. Create an endpoint with your URL and select the events you want.
4. Copy the signing secret and store it server-side, for example as `COASSEMBLE_WEBHOOK_SECRET`.
5. Send a test delivery and check that your receiver verifies it and skips production actions for `testMode: true`.

Integrations that register their own receiver, such as an LMS plugin, can list, create and delete endpoints with the Webhooks API instead. The API returns the signing secret once, on creation, so store it then.

If you receive Coassemble webhooks through Hookdeck, use the Hookdeck source URL as the endpoint, create a source with the Coassemble type and paste the endpoint's signing secret. The Coassemble source accepts `POST` only, which is how Coassemble delivers webhooks.

## Securing Coassemble webhooks

Read `X-Coassemble-Timestamp` and `X-Coassemble-Signature`. Build the message from the timestamp value, a period and the raw body bytes, compute an HMAC-SHA256 with the endpoint secret, and compare the lowercase hex digest with the part of the header after `sha256=` in constant time.

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

function verifyCoassembleSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  const hex = signatureHeader.startsWith('sha256=') ? signatureHeader.slice(7) : signatureHeader;
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) return false;

  // Optional replay window. Off by default (see below).
  if (toleranceSeconds > 0) {
    const timestamp = Number(timestampHeader);
    if (!Number.isFinite(timestamp)) return false;
    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  }

  const message = Buffer.concat([Buffer.from(timestampHeader, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(message).digest();
  const received = Buffer.from(hex, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/coassemble', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.COASSEMBLE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyCoassembleSignature(
    req.body,
    req.get('x-coassemble-signature'),
    req.get('x-coassemble-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  if (event.testMode) return res.sendStatus(200); // verified test delivery, no side effects
  // Deduplicate on the X-Coassemble-Delivery header, route on event.type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

def verify_coassemble_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                                tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    digest = signature_header[7:] if signature_header.startswith("sha256=") else signature_header
    if not re.fullmatch(r"[0-9a-fA-F]{64}", digest):
        return False
    # Optional replay window. Off by default (see below).
    if tolerance_seconds > 0:
        try:
            timestamp = float(timestamp_header)
        except ValueError:
            return False
        if abs(time.time() - timestamp) > tolerance_seconds:
            return False
    message = timestamp_header.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(digest), expected)

# In FastAPI: raw_body = await request.body()
# verify_coassemble_signature(raw_body, request.headers.get("x-coassemble-signature"),
#                             request.headers.get("x-coassemble-timestamp"),
#                             os.environ["COASSEMBLE_WEBHOOK_SECRET"])

```

Coassemble's own Node example interpolates `rawBody` into a template string. That works only if `rawBody` is the exact body string. Hashing the bytes, as above, avoids any re-encoding.

Coassemble's docs don't set a timestamp tolerance, and the samples above leave the window off by default. Hookdeck applies no timestamp tolerance either, and its retries and replays resend Coassemble's original headers, so a window on your endpoint would reject late ones. If you want one, pass `toleranceSeconds` and rely on `X-Coassemble-Delivery` for duplicates.

To check your verifier, use the secret `coassemble-guide-secret`, an `X-Coassemble-Timestamp` of `1771755330` and this exact body:

```
{"id":"17fd9df8-c77a-4b7d-a281-267b74f8cbf3","type":"course.completed","testMode":false}

```

The header should be:

```
X-Coassemble-Signature: sha256=896a2fd7d72f7b8e05a79e5929000523fdf26cfc8a8b2d7ec844cbd8fc792ce4

```

> **Make Coassemble webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Coassemble-Signature` header on every delivery, answers inside Coassemble's 10-second timeout, and queues each learner event until your service has processed it.

## Coassemble webhook limitations and pain points

### A 10-second timeout and an 8.5-hour retry budget

**The Problem:** Completions go missing after a deploy or an outage of a few hours.

**Why It Happens:** Coassemble waits 10 seconds for a `2xx`. A failed delivery is retried at about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then stops. Deliveries that were already pending when Coassemble changed its schedule keep the older budget of 4 attempts.

**Workarounds:**

* Verify, store and return `2xx` before doing any work.
* Use Coassemble's manual replay for deliveries that ran out of attempts during an outage.
* Reconcile course completions against Coassemble's tracking API for records that matter, such as compliance training.

**How Hookdeck Can Help:** Hookdeck acknowledges each delivery as soon as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, well beyond Coassemble's 8.5 hours.

### The signing secret is shown once

**The Problem:** You can't find an endpoint's secret after setup, so you can't verify its deliveries.

**Why It Happens:** Each endpoint has its own secret, and the Webhooks API returns it only in the create response.

**Workarounds:**

* Store the secret as soon as you create the endpoint.
* If it's lost, create a new endpoint, store its secret, and delete the old one.

**How Hookdeck Can Help:** Hookdeck keeps the secret on the source, so only one place needs it. Deliveries that fail verification stay in the request log, marked as failed, so a wrong secret is easy to spot.

### Test deliveries look like live ones

**The Problem:** A test delivery creates a real record in your LMS or CRM.

**Why It Happens:** Test deliveries are signed and delivered like live ones, including in Sandbox. Only `testMode: true`, `data.test: true` and the `X-Coassemble-Test-Mode` header mark them. Deliveries queued before Coassemble introduced the field may have no `testMode` at all.

**Workarounds:**

* Check `testMode` after verification and skip side effects when it's `true`.
* Treat a missing `testMode` as unclassified rather than as live, as Coassemble advises.

**How Hookdeck Can Help:** A Hookdeck [filter](/docs/filters) on `testMode` can send test deliveries to a separate destination, or drop them, before they reach production.

### Events cover first starts and first completions only

**The Problem:** A learner retakes a course and nothing arrives.

**Why It Happens:** `course.commenced` and `course.completed` fire when a tracking record is first commenced or first completed. Quiz events cover standalone Screen Player quizzes only, and the migration events skip one-course migrations.

**Workarounds:**

* Read later progress from the tracking API when you need it.
* Don't wait for migration webhooks after a single-course migration. Read its result from the migration API instead.

**How Hookdeck Can Help:** Hookdeck can't add events Coassemble doesn't send, but its request log shows exactly which events did arrive, which helps when a missing completion is reported.

## Best practices

### Verify against the raw body

Coassemble signs the exact bytes it sends. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`. Re-serialised JSON fails.

### Deduplicate on `X-Coassemble-Delivery`

Retries and manual replays carry the same `X-Coassemble-Delivery` UUID and the original payload. Record each ID you've processed and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) and Hookdeck's [deduplication](/docs/deduplication).

### Join on the learner identifier

Use `data.tracking.identifier`, the identifier you set when embedding, rather than the email address, so records still match if an address changes.

### Keep one endpoint per environment

Secrets are per endpoint. Separate endpoints for staging and production keep test traffic and secrets apart.

## Conclusion

Coassemble's webhooks are small and well specified: seven events, one envelope, a per-endpoint secret and a `sha256=` signature over the timestamp and raw body. The parts that need care are the one-time secret, test deliveries that carry real-looking data, and a retry schedule that ends after about 8.5 hours.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Coassemble signature, and holds each event until your service has processed it.

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