Receive real-time event notifications from Immersive when activity happens in your organisation.
This document is for engineers building the HTTP server that will receive our webhook deliveries. It covers what the server must do to (1) pass our verification handshake and (2) accept and authenticate event payloads.
Configuration of the webhook URL, signing secret and subscribed events is handled in the Immersive UI / API and is out of scope for this document. Once a webhook is configured, you will have a URL (your endpoint) and a secret (a 64-character hex string) to use when implementing the receiver below.
Overview
Immersive sends webhooks as POST requests to a single URL that you provide. All requests — both the verification handshake and event deliveries — go to that same URL. Your server distinguishes them by inspecting the event field of the JSON body.
Every request is signed with HMAC-SHA256 using your webhook's secret. Your server must verify that signature on every request before trusting the payload.
Your endpoint
You provide a single HTTPS URL. We will POST to that exact URL. We do not append paths or query parameters.
Requirements:
| Requirement | Detail |
| Scheme | https:// (required in production) |
| Host | Must resolve publicly. Private, loopback, link-local, multicast and other reserved IP ranges are rejected at registration time. |
| TLS | Standard TLS certificate verification applies. Self-signed certificates are not supported. |
| Method accepted | POST |
| Response time | Respond within 10 seconds. We close the connection after that. |
| Success status | Any 2xx |
Request format
Every request we send has this shape:
Method: POST
Headers:
| Header | Value | Notes |
| Content-Type | application/json | |
| User-Agent | Immersive-Webhooks/1.0 | |
| X-Immersive-Webhook-Version | 1 | Payload schema version. Pin to 1 for now. |
| X-Immersive-Signature | sha256=<hex> | Event deliveries only. Not sent on the verification handshake — see the verification handshake. |
Body: UTF-8 JSON with this envelope:
{
"version": "1",
"event": "<event-name>",
"data": { /* event-specific payload */ }
}
The event field tells you what kind of request this is. The three values you need to handle today are:
- "challenge" — the verification handshake (sent once when your URL is registered or its URL is changed, and any time you trigger re-verification)
- "completion.program_deployment_participant" — a Program deployment participant completion event
- "non_completion.program_deployment_participant" — sent when an AI Knowledge Pass Program deployment ends, for each participant who is still enrolled and has not completed it.
Signing & verification
Every request body is signed with HMAC-SHA256 using your webhook's secret as the key.
Algorithm:
signature = "sha256=" + lowercase_hex(HMAC_SHA256(secret, raw_request_body))
Notes that matter for getting this right:
- The signed input is the raw HTTP request body as bytes — exactly what arrived on the wire. Do not re-serialize the JSON before computing the digest, or whitespace, key ordering or unicode escapes will throw the comparison off.
- The encoding is lowercase hexadecimal, prefixed with sha256=.
- Use a constant-time comparison when verifying (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). A naive == is timing-attack vulnerable.
- The secret is a 64-character hex string. Treat it as the literal ASCII bytes of that string when used as the HMAC key — do not hex-decode it first.
The same algorithm is used in two places, but it's used differently:
| Where | Header on the request? | What you do with it |
| Event deliveries | Yes — we send X-Immersive-Signature | Recompute it and compare. Reject if it doesn't match. |
| Verification handshake | No — header absent | Recompute it and return it in the response body. See below. |
The verification handshake
When your URL is registered (or changed, or re-verified manually), we send a single handshake request to confirm that whoever controls the URL also holds the signing secret.
What we send:
POST /your-webhook-path HTTP/1.1
Host: your-domain.example
Content-Type: application/json
User-Agent: Immersive-Webhooks/1.0
X-Immersive-Webhook-Version: 1
{
"version": "1",
"event": "challenge",
"data": {
"challenge": "<64-char-hex-token>"
}
}
Note: X-Immersive-Signature is intentionally not sent on this request. The whole point of the handshake is that you prove possession of the secret by computing the signature yourself.
What you must return:
- HTTP status 200
- Content-Type: application/json
- A JSON body whose challenge field contains the HMAC-SHA256 signature of the request body (the same sha256=<hex> string you would compute for any other request):
{
"challenge": "sha256=ab12cd34..."
}
If the value matches what we expect, your webhook transitions from pending_verification to active and we will start sending events. If it doesn't match, the webhook stays in pending_verification and no events are sent.
The handshake fails (and your webhook is not activated) if any of the following are true:
- The response status is not 2xx.
- The body is not valid JSON.
- The JSON response must include the expected challenge value.
- The request takes longer than 10 seconds.
You can re-trigger the handshake at any time from the configuration UI.
Event delivery
Once your webhook is active, every event you're subscribed to is delivered as:
POST <your-url> HTTP/1.1
Content-Type: application/json
User-Agent: Immersive-Webhooks/1.0
X-Immersive-Webhook-Version: 1
X-Immersive-Signature: sha256=<hex>
{
"version": "1",
"event": "<event-name>",
"data": { /* see Events below */ }
}
On every event, your handler must:
- Read the raw request body before parsing it as JSON.
- Compute sha256= + HMAC-SHA256(secret, raw body).
- Compare it to the X-Immersive-Signature header using a constant-time comparison.
- Reject the request (e.g. 401 Unauthorized) if it doesn't match. Do not proceed to act on the payload.
- Only after the signature passes, parse the body and dispatch on event.
Respond with any 2xx once you have safely accepted the event (typically after enqueuing it for processing). Do not do slow work in the handler — keep the response under the 10-second timeout. We recommend acknowledging immediately and processing asynchronously.
You may receive duplicate deliveries (see Retries). Treat the handler as idempotent.
For completion.program_deployment_participant, dedupe on the event plus data.account.uuid and data.content.uuid. For non_completion.program_deployment_participant, dedupe on the event plus data.uuid. That uuid is the participant's enrolment on the deployment.
data.account.uuid alone is not enough for completion, because the same person can complete more than one deployment. data.uuid is enough for non-completion, because that id is already one enrolment.
Events
completion.program_deployment_participant
Sent when a participant completes a Program deployment in your organisation.
Envelope:
{
"version": "1",
"event": "completion.program_deployment_participant",
"data": { /* see below */ }
}
data payload:
| Field | Type | Required | Description |
| completed_at | string (RFC 3339 date-time) | yes | When the participant completed the deployment. |
| account | object | yes | The participant who completed it. |
| account.uuid | string (UUID) | yes | Stable identifier for the account. |
| account.display_name | string | yes | The participant's profile display name. |
| account.full_name | string | no | The participant's full name, if available. |
| account.email | string | no | The participant's email, if available. |
| content | object | yes | The Program deployment that was completed. |
| content.type | string | yes | Always "program_deployment" for this event. |
| content.uuid | string (UUID) | yes | Stable identifier for the Program deployment. |
| content.title | string | yes | Deployment title. |
| content.scheduled_start_time | string (RFC 3339) | yes | The deployment's scheduled start. |
| content.scheduled_end_time | string (RFC 3339) | yes | The deployment's scheduled end. |
| content.status | string | yes | The deployment's workflow state (e.g. running). |
| content.http_url | string (URL) | yes | A link to the deployment in the Immersive UI. |
| content.ai_knowledge_pass_program | boolean | yes | Whether the underlying Program is an AI Knowledge Pass program. |
Example:
{
"version": "1",
"event": "completion.program_deployment_participant",
"data": {
"completed_at": "2026-06-19T14:32:11Z",
"account": {
"uuid": "8f4c0b1d-2a3e-4f5b-9c6d-7e8f9a0b1c2d",
"display_name": "Ash Ketchum",
"full_name": "Ash Ketchum",
"email": "ash.ketchum@example.com"
},
"content": {
"type": "program_deployment",
"uuid": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"title": "Q2 Phishing Awareness",
"scheduled_start_time": "2026-06-01T00:00:00Z",
"scheduled_end_time": "2026-06-30T23:59:59Z",
"status": "running",
"http_url": "https://app.immersivelabs.online/programs/deployments/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"ai_knowledge_pass_program": false
}
}
}
non_completion.program_deployment_participant
Sent when an enrolled participant does not complete a Program deployment in your organisation.
Envelope:
{
"version": "1",
"event": "non_completion.program_deployment_participant",
"data": { /* see below */ }
}
data payload:
| Field | Type | Required | Description |
| uuid | String (UUID) | yes | Stable identifier for the participant’s enrollment on this deployment. |
| progress_status | string | yes | How far the participant got through the deployment. |
| account | object | yes | The participant. |
| account.uuid | string (UUID) | yes | Stable identifier for the account. |
| account.display_name | string | yes | The participant's profile display name. |
| account.full_name | string | no | The participant's full name, if available. |
| account.email | string | no | The participant's email, if available. |
| deployment | object | yes | The Program deployment that ended. |
| deployment.type | string | yes | Always "program_deployment" for this event. |
| deployment.uuid | string (UUID) | yes | Stable identifier for the Program deployment. |
| deployment.title | string | yes | Deployment title. |
| deployment.scheduled_start_time | string (RFC 3339) | yes | The deployment's scheduled start. |
| deployment.scheduled_end_time | string (RFC 3339) | yes | The deployment's scheduled end. |
| deployment.status | string | yes | The deployment's workflow state (e.g. ended). |
| deployment.http_url | string (URL) | yes | A link to the deployment in the Immersive UI. |
| deployment.ai_knowledge_pass_program | boolean | yes | Whether the underlying Program is an AI Knowledge Pass program. |
| program | object | yes | The Program the deployment is for. |
| program.uuid | string (UUID) | yes | Stable identifier for the Program. |
| program.title | string | yes | Program title. |
Example:
{
"version": "1",
"event": "non_completion.program_deployment_participant",
"data": {
"uuid": "6c1e9a20-4b5d-4e6f-8a7b-9c01de2f3a4b",
"progress_status": "not_started",
"account": {
"uuid": "8f4c0b1d-2a3e-4f5b-9c6d-7e8f9a0b1c2d",
"display_name": "Ash Ketchum",
"full_name": "Ash Ketchum",
"email": "ash.ketchum@example.com"
},
"deployment": {
"type": "program_deployment",
"uuid": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"title": "Q2 Phishing Awareness",
"scheduled_start_time": "2026-06-01T00:00:00Z",
"scheduled_end_time": "2026-06-30T23:59:59Z",
"status": "ended",
"http_url": "https://app.immersivelabs.online/programs/deployments/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"ai_knowledge_pass_program": true
},
"program": {
"uuid": "9d8c7b6a-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"title": "AI Knowledge Pass"
}
}
}
Retries & deactivation
If your endpoint returns a non-2xx response, times out, errors, we treat the delivery as failed and retry for a period of time.
We will email the creator of the webhook a warning email once we start receiving non-2xx responses.
If all retries fail for a given request, the webhook is deactivated and the owner is emailed. Deactivated webhooks receive no further events until they are reactivated and re-verified.
Because of retries, your handler must be idempotent. The same event may be delivered more than once.
Worked example: Node.js (Express)
const express = require("express");
const crypto = require("crypto");
const SECRET = process.env.IMMERSIVE_WEBHOOK_SECRET; // the 64-char hex string
const app = express();
// IMPORTANT: capture the raw body. The signature is computed over the exact
// bytes that arrived; re-serializing parsed JSON will not match.
app.use(
"/webhooks/immersive",
express.raw({ type: "application/json", limit: "1mb" })
);
function expectedSignature(rawBody) {
const digest = crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
return `sha256=${digest}`;
}
function timingSafeEqual(a, b) {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
function parseJson(rawBody, res) {
try {
const payload = JSON.parse(rawBody.toString("utf8"));
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
res.status(400).send("invalid json");
return null;
}
return payload;
} catch {
res.status(400).send("invalid json");
return null;
}
}
app.post("/webhooks/immersive", (req, res) => {
const raw = req.body; // Buffer, because of express.raw()
const expected = expectedSignature(raw);
const provided = req.get("X-Immersive-Signature");
// Event delivery: verify the signature header before parsing JSON.
if (provided) {
if (!timingSafeEqual(provided, expected)) {
return res.status(401).send("bad signature");
}
const payload = parseJson(raw, res);
if (!payload) return;
switch (payload.event) {
case "completion.program_deployment_participant":
// Hand off to a queue. Don't do slow work inline.
enqueueParticipantCompletion(payload.data);
break;
case "non_completion.program_deployment_participant":
// Hand off to a queue. Don't do slow work inline.
enqueueParticipantNonCompletion(payload.data);
break;
default:
// Accept unsupported event types.
break;
}
return res.status(204).end();
}
// Verification handshake: no signature header is sent.
// Echo the expected signature back in `challenge`.
const payload = parseJson(raw, res);
if (!payload) return;
if (payload.event === "challenge") {
return res.status(200).json({ challenge: expected });
}
return res.status(401).send("missing signature");
});
function enqueueParticipantCompletion(data) {
// your job queue here
}
function enqueueParticipantNonCompletion(data) {
// your job queue here
}
app.listen(3000);
Worked example: Python (Flask)
import hmac
import hashlib
import json
import os
from flask import Flask, request, abort, jsonify
SECRET = os.environ["IMMERSIVE_WEBHOOK_SECRET"].encode("utf-8")
app = Flask(__name__)
def expected_signature(raw_body: bytes) -> str:
digest = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def parse_payload(raw: bytes) -> dict:
try:
payload = json.loads(raw.decode("utf-8"))
except ValueError:
abort(400, "invalid json")
if not isinstance(payload, dict):
abort(400, "invalid json")
return payload
@app.post("/webhooks/immersive")
def receive():
# IMPORTANT: use the raw body. Do not re-serialise request.json.
raw = request.get_data()
expected = expected_signature(raw)
provided = request.headers.get("X-Immersive-Signature")
# Event delivery: verify the signature header before parsing JSON.
if provided:
if not hmac.compare_digest(provided, expected):
abort(401, "bad signature")
payload = parse_payload(raw)
event = payload.get("event")
if event == "completion.program_deployment_participant":
enqueue_participant_completion(payload["data"])
elif event == "non_completion.program_deployment_participant":
enqueue_participant_non_completion(payload["data"])
# Accept unsupported event types.
return "", 204
# Verification handshake: no signature header is sent.
payload = parse_payload(raw)
event = payload.get("event")
if event == "challenge":
return jsonify({"challenge": expected}), 200
abort(401, "missing signature")
def enqueue_participant_completion(data):
# your job queue here
pass
def enqueue_participant_non_completion(data):
# your job queue here
pass
(FastAPI is structurally identical — read the body with await request.body(), then apply the same logic.)
Quick checklist
- [ ] Endpoint is HTTPS and publicly resolvable.
- [ ] Reads the raw request body before parsing JSON.
- [ ] Computes sha256= + lowercase hex of HMAC-SHA256(secret, raw body).
- [ ] On event == "challenge": returns 200 with {"challenge": "<expected-signature>"} and no signature-header check.
- [ ] On any other event: compares the computed signature to X-Immersive-Signature using a constant-time comparison and rejects on mismatch.
- [ ] Responds within 10 seconds, body under 64 KB.
- [ ] Handler is idempotent — deduplicates on the event plus identifiers in data.
-
[ ] Secret is stored alongside other application secrets, not in source control.