Gareth Wilson Gareth Wilson

Guide to Coassemble Webhooks: Features and Best Practices

Published


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

FeatureDetails
PlanBuild and above
ConfigurationDeveloper > Webhooks in Coassemble authoring, or the Webhooks API
Endpoint URLHTTPS only
Signature headerX-Coassemble-Signature, in the form sha256=<hex>
Timestamp headerX-Coassemble-Timestamp, Unix seconds
Signed content<timestamp>.<raw body>
SecretOne per endpoint, returned once at creation
Event fieldtype in the body, also sent in X-Coassemble-Event
Delivery IDX-Coassemble-Delivery, a UUID that stays the same across retries
Test deliveriestestMode: true and data.test: true, signed like live ones
Success responseAny 2xx within 10 seconds
RetriesUp to 6 attempts over about 8.5 hours
ReplayManual, after automatic retries stop

Common events

EventFires when
course.createdA new course is created
course.commencedA learner first commences a course tracking record
course.completedA learner first completes a course tracking record
quiz.completedA learner passes a standalone Screen Player quiz
quiz.failedA learner fails a standalone Screen Player quiz
migration.progressOne course in a Classic migration batch finishes (migrated, blocked or failed)
migration.completedA 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:

{
  "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.

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:

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 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 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 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 and Hookdeck's 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 sits in front of your receiver, checks every Coassemble signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Coassemble webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.