Integration · Reference
Webhooks
Send signed learner and notification events to your systems.
Use webhooks to receive learner and notification events instead of polling the API.
Create a webhook
- Open Admin > Webhooks and create a webhook.
- Enter a public endpoint URL.
- Select events, or leave the list empty to receive all events.
- Add any custom headers, enable the webhook, and save.
- Copy the signing secret to your secret manager.
Each organization can have up to five webhooks. Disabling one pauses new deliveries without deleting it.
Events
| Event | Sent when |
|---|---|
learner.started | A learner starts an activity. |
learner.updated | A learner's saved attempt changes. |
learner.completed | An attempt becomes completed, passed, or failed. |
course.invite | A learner is invited to a course. |
course.request | A learner requests access to a course. |
course.request.accepted | A course access request is accepted. |
course.request.rejected | A course access request is rejected. |
course.completion | A course completion notification is created. |
course.inactive | An inactive-course notification is created. |
collection.invite | A learner is invited to a collection. |
collection.request | A learner requests access to a collection. |
collection.request.accepted | A collection access request is accepted. |
collection.request.rejected | A collection access request is rejected. |
collection.completion | A collection completion notification is created. |
Kokobi sends an HTTP POST containing { "event": string, "data": object }. Dates are ISO 8601 strings. An empty event selection also receives event types added later. Ignore fields your handler does not use.
Headers and signatures
Every delivery includes:
Content-Type: application/json
webhook-timestamp: 2026-07-24T12:00:00.000Z
webhook-signature: <hex-encoded-signature>Custom headers are included too. Content-Type, webhook-timestamp, and webhook-signature are reserved.
The signature is the hex-encoded HMAC-SHA256 digest of:
timestamp + "." + raw_request_bodyVerify it against the exact body bytes before parsing JSON:
import { createHmac, timingSafeEqual } from "node:crypto";
const verify = (body: Buffer, timestamp: string, signature: string) => {
const expected = createHmac("sha256", process.env.KOKOBI_WEBHOOK_SECRET!)
.update(timestamp + ".")
.update(body)
.digest();
const provided = Buffer.from(signature, "hex");
return (
provided.length === expected.length && timingSafeEqual(provided, expected)
);
};Also reject old timestamps to limit replay attacks. Never expose the signing secret in client-side code.
Delivery
A 2xx response marks a delivery successful. Network errors and other responses are retried with increasing delays.
Verify the signature, persist or enqueue the event, and return quickly. Process slow work asynchronously and deduplicate events because retries may deliver the same payload more than once. Completed delivery records become eligible for cleanup after seven days.
Troubleshooting
- Pending delivery: Confirm the URL is public, accepts JSON
POSTrequests, and returns2xx. - Invalid signature: Use the correct webhook secret and the unmodified raw body. Do not add a newline.
- Missing event: Confirm the webhook is enabled, its event filter matches, and the action occurred in the same organization.
Check Kokobi's delivery log and your firewall, proxy, and application logs before retrying.
Event data types
The following types describe each event's data property. Dates are ISO 8601 strings. Fields may be added over time, so ignore properties your handler does not use.
Learner events
learner.started, learner.updated, and learner.completed share the same structure.
export type LearnerStartedData = LearnerEventData;
export type LearnerUpdatedData = LearnerEventData;
export type LearnerCompletedData = LearnerEventData;
export type LearnerEventData = {
user: {
id: string;
email: string;
name: string;
};
connection: {
userId: string;
organizationId: string;
externalId: string | null;
respondedAt: string | null;
connectType: "invite" | "request";
connectStatus:
| "pending"
| "accepted"
| "rejected"
| "disconnected"
| "removed";
createdAt: string;
updatedAt: string;
} & ({ courseId: string } | { collectionId: string });
attempt: {
id: string;
userId: string;
organizationId: string;
moduleId: string;
courseId: string;
completedAt: string | null;
data: Record<string, string>;
status: "completed" | "passed" | "failed" | "in-progress" | "not-started";
score?: { raw?: number; min?: number; max?: number };
module: {
id: string;
name: string | null;
courseId: string;
locale: "en" | "fr";
multilingual: boolean;
type: "1.2" | "2004";
versionNumber: number;
sizeBytes: number | null;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
};
createdAt: string;
updatedAt: string;
} | null;
};Course events
export type CourseInviteData = {
to: string[];
data: { id: string; reminderDays?: 14 | 30 | 90 };
};
export type CourseInactiveData = {
to: string[];
data: { id: string; reminderDays?: 30 | 90 };
};
export type CourseCompletionData = {
to: string[];
data: { id: string };
};
export type CourseRequestData = {
to: string[];
data: {
id: string;
userId: string;
userEmail: string;
userName?: string;
};
};
export type CourseRequestAcceptedData = {
to: string[];
data: { id: string };
};
export type CourseRequestRejectedData = {
to: string[];
data: { id: string };
};Collection events
export type CollectionInviteData = {
to: string[];
data: { id: string; reminderDays?: 14 | 30 | 90 };
};
export type CollectionCompletionData = {
to: string[];
data: { id: string };
};
export type CollectionRequestData = {
to: string[];
data: {
id: string;
userId: string;
userEmail: string;
userName?: string;
};
};
export type CollectionRequestAcceptedData = {
to: string[];
data: { id: string };
};
export type CollectionRequestRejectedData = {
to: string[];
data: { id: string };
};