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:
{
"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
- Build an HTTPS endpoint that accepts JSON
POSTrequests and returns a2xxquickly. - In Coassemble authoring, go to Developer > Webhooks.
- Create an endpoint with your URL and select the events you want.
- Copy the signing secret and store it server-side, for example as
COASSEMBLE_.WEBHOOK_ SECRET - 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-Signatureheader 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
2xxbefore 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
testModeafter verification and skip side effects when it'strue. - Treat a missing
testModeas 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.