Public API & webhooks
Developer reference for CLIP Learn's read-only REST API v1 and outbound webhooks: minting keys, authentication, endpoints, event payloads, and the HMAC signature.
CLIP Learn exposes a small, read-only REST API and an outbound webhook system so you can pull your tenant's data into other tools (a data warehouse, a CRM, Zapier, a spreadsheet sync) and be notified when things happen (an assignment is marked, an essay result is released). Everything is scoped to a single tenant: an API key can only ever read the data of the tenant that created it, and a webhook only fires for events in its own tenant.
Both keys and webhooks are managed from Faculty → API & webhooks (/faculty/api). Only users with the FACULTY or ADMIN role can open that page or create, revoke, or delete keys and webhooks. There is no self-service API for creating data — every v1 endpoint is a GET.
Base URL
All examples use the production host https://cliplaw.academy. Substitute your own tenant's host if you have been given a different one. Every endpoint below lives under the /api/v1/ path.
Authentication
The API uses a bearer token in the Authorization header. There are no query-string keys, no cookies, and no OAuth.
Authorization: Bearer <your-key>The server takes the key, hashes it with SHA-256, and looks it up. If the key is unknown, malformed, or has been revoked, the request is rejected. On every successful call the key's Last used timestamp is updated.
| Condition | Response |
|---|---|
| Valid, active key | 200 with a JSON body { "data": [...] } |
Missing Authorization header | 401 { "error": "invalid or missing API key" } |
Malformed header (not Bearer <key>) | 401 { "error": "invalid or missing API key" } |
| Unknown key | 401 { "error": "invalid or missing API key" } |
| Revoked key | 401 { "error": "invalid or missing API key" } |
One error for all auth failures
The API deliberately returns the same 401 message whether the key is missing, malformed, unknown, or revoked. It does not tell you which. If you get a 401, check the header format first, then confirm the key has not been revoked on the API page.
Minting and revoking API keys
Keys are created on the API & webhooks page under the API keys section.
Create a key
Zapier sync). This is a label to help you recognise the key later; it does not affect what the key can do.The plaintext key is shown exactly once
CLIP Learn stores only a SHA-256 hash of your key, never the key itself. After you leave or refresh the page the full value is gone forever — the table only ever shows the short prefix (for example clip_a1B2c3D…). If you lose a key, revoke it and generate a new one.
Key name field
| Field | What it does | Notes |
|---|---|---|
| Key name | A human label shown in the keys table | Optional. Trimmed and truncated to 80 characters. If left blank, the key is named API key. |
Key format. A generated key looks like clip_ followed by a URL-safe base64 string, e.g. clip_qX8f2.... The prefix stored for display is the first 12 characters (clip_ plus the first seven characters of the random part).
The keys table
Each row in the API keys table shows:
| Column | Meaning |
|---|---|
| Name | The label you gave the key (or API key). |
| Key | The stored prefix followed by …. The full key is never shown here. |
| Created | The date the key was generated (formatted like 9 Jul 2026). |
| Last used | The date the key last authenticated a request, or — if never used. |
| (action) | A Revoke link, or the word revoked if it has already been revoked. |
If you have no keys yet, the table is hidden entirely and only the Generate key form and the Try it example are shown.
Revoke a key
Revocation is immediate and permanent — the row's action cell changes to revoked, and any request using that key from that moment on returns 401. There is no "un-revoke"; generate a new key instead. Revoking one key never affects any other key.
Endpoints
All three endpoints are GET, require a valid bearer key, run on the Node.js runtime, and are always dynamic (never cached). Each returns a JSON object with a single data array. Results are automatically filtered to the key's tenant — there is no tenant parameter to pass, and you cannot read another tenant's data.
GET /api/v1/courses
Returns every course belonging to the key's tenant, ordered by title (A→Z).
Response
{
"data": [
{
"id": "clx...",
"slug": "company-law",
"title": "Company Law",
"subtitle": "Foundations of UK company law",
"published": true
}
]
}| Field | Type | Meaning |
|---|---|---|
id | string | The course's internal identifier. |
slug | string | The URL slug for the course. |
title | string | The course title. |
subtitle | string | null | The course subtitle, if set. |
published | boolean | Whether the course is published (visible to learners) or still a draft. |
GET /api/v1/learners
Returns the tenant's learners — users with the CANDIDATE role who are members of the tenant. Ordered by name (A→Z), capped at 1000 rows.
Response
{
"data": [
{
"id": "clx...",
"name": "Amina Yusuf",
"email": "amina@example.com",
"createdAt": "2026-05-01T09:12:00.000Z"
}
]
}| Field | Type | Meaning |
|---|---|---|
id | string | The learner's user identifier. |
name | string | The learner's display name. |
email | string | The learner's email address. |
createdAt | string (ISO 8601) | When the learner's account was created. |
Only CANDIDATE users are returned
/api/v1/learners returns only users whose role is CANDIDATE. Faculty, admins, and other staff accounts are never included, even if they belong to the tenant. The list is capped at 1000 learners; there is currently no pagination parameter.
GET /api/v1/enrollments
Returns enrolments for all of the tenant's courses. Ordered by start date (most recent first), capped at 2000 rows.
Response
{
"data": [
{
"userId": "clx...",
"courseId": "clx...",
"startedAt": "2026-06-10T00:00:00.000Z",
"expiresAt": "2027-06-10T00:00:00.000Z"
}
]
}| Field | Type | Meaning |
|---|---|---|
userId | string | The enrolled learner's user id (join to /api/v1/learners). |
courseId | string | The course id (join to /api/v1/courses). |
startedAt | string (ISO 8601) | When the enrolment began. |
expiresAt | string (ISO 8601) | null | When the enrolment expires, or null if it never expires. |
The response contains only ids and dates — join userId against /api/v1/learners and courseId against /api/v1/courses to get names and titles. The list is capped at 2000 enrolments with no pagination parameter.
Example request
curl https://cliplaw.academy/api/v1/courses \
-H "Authorization: Bearer <your-key>"The same header works for /api/v1/learners and /api/v1/enrollments.
Webhooks
Webhooks push a small JSON payload to a URL you control whenever a subscribed event happens in your tenant. They are managed on the same API & webhooks page, under the Webhooks section.
Add a webhook
http:// or https://.* to receive every event type.Webhook form fields
| Field | What it does | Notes |
|---|---|---|
| Endpoint URL | The destination CLIP Learn POSTs events to | Required. Must start with http:// or https:// or the form silently does nothing. Truncated to 500 characters. |
| Event | Which event type this webhook subscribes to | One of *, assignment.graded, enrollment.created, exam.result. Defaults to *. |
Each webhook in the list shows its URL, its subscribed event, the first 12 characters of its signing secret, and — if any deliveries have failed — a failure count (for example 3 failures).
If no webhooks exist yet
When you have no webhooks, the section shows the reminder: "No webhooks yet. Payloads are signed with x-clip-signature (HMAC-SHA256)."
Delete a webhook
Deletion is immediate; the webhook stops receiving events at once. There is no per-webhook "pause" toggle in the UI — to stop deliveries, delete the webhook.
Events
| Event | When it fires | data payload fields |
|---|---|---|
assignment.graded | A faculty member marks an assignment submission | submissionId, userId, assignment (the assignment title), score, maxScore |
exam.result | An essay submission is reviewed and its result is released | submissionId, userId, topic (the essay question topic), marks, maxMarks |
enrollment.created | (subscribable, not currently emitted) | — |
* | Wildcard — receives every event above | The payload of whatever event fired |
enrollment.created is selectable but not yet emitted
You can create a webhook subscribed to enrollment.created, but the current build does not dispatch that event anywhere, so such a webhook (and the enrollment.created slice of a * webhook) will not receive anything. The two events actually delivered today are assignment.graded and exam.result. Subscribing to * is the safe way to future-proof against new events being added.
Payload shape
Every delivery is an HTTP POST with a JSON body of this shape:
{
"event": "assignment.graded",
"at": "2026-07-12T14:03:00.000Z",
"data": {
"submissionId": "clx...",
"userId": "clx...",
"assignment": "Problem question — directors' duties",
"score": 18,
"maxScore": 25
}
}| Top-level field | Meaning |
|---|---|
event | The event type that fired (e.g. assignment.graded). For a * subscription this is the concrete event, never *. |
at | ISO 8601 timestamp of when the event was dispatched. |
data | The event-specific payload from the table above. |
An exam.result delivery's data instead contains submissionId, userId, topic, marks, and maxMarks.
Request headers and signature
Each delivery carries these headers:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | CLIPLearn-Webhook/1.0 |
x-clip-event | The event name (e.g. assignment.graded) |
x-clip-signature | sha256=<hex> — an HMAC-SHA256 of the raw request body, keyed with this webhook's secret |
The signature is computed as HMAC_SHA256(secret, rawBody), hex-encoded, prefixed with sha256=. To verify a delivery, compute the same HMAC over the exact raw bytes you received (do not re-serialise the JSON) using the webhook's secret, and compare — ideally with a constant-time comparison.
Verification example (Node.js):
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" +
createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Where secret is the full whsec_… value shown when you created the webhook, rawBody is the unparsed request body, and signatureHeader is the value of x-clip-signature.
The signing secret
Each webhook gets its own secret, generated as whsec_ followed by a random URL-safe base64 string. The UI shows only its first 12 characters; the full secret is set at creation. Treat it like a password — anyone who has it can forge signed payloads. If a secret is exposed, delete the webhook and create a new one to rotate it.
Delivery behaviour
- Fan-out. When an event fires, it is delivered to every active webhook in the tenant that is subscribed to that event or to
*. Deliveries happen in parallel. - Fire-and-forget. Delivery never blocks or breaks the action that triggered it (grading still succeeds even if your endpoint is down). There are no automatic retries.
- Success vs failure. A
2xxresponse resets the webhook's failure count to zero. Any non-2xxresponse, a timeout, or a connection error increments its failure count, which is displayed next to the webhook. - Timeout. Each delivery has a 6-second timeout; slow endpoints are treated as failures.
- SSRF protection. Delivery URLs are validated and IP-pinned. Requests that resolve to private, loopback, or otherwise internal address ranges are blocked, and redirects are not followed. Point your webhook at a publicly reachable HTTPS endpoint.
Respond fast with a 2xx
Your endpoint should acknowledge with a 2xx status within 6 seconds and do any heavy processing asynchronously afterwards. Because there are no retries, a slow or failing endpoint simply misses that event — the failure count rises but the event is not re-sent.
Permissions summary
| Action | Who can do it |
|---|---|
| View the API & webhooks page | FACULTY or ADMIN |
| Generate / revoke an API key | FACULTY or ADMIN |
| Add / delete a webhook | FACULTY or ADMIN |
Call a /api/v1/* endpoint | Anyone holding a valid, non-revoked bearer key (scoped to that key's tenant) |
Keys and webhooks are always tenant-scoped: they only ever read or fire for the tenant that owns them, and there is no cross-tenant access.
xAPI statements (LRS integration)
CLIP Learn exposes learner activity as xAPI (Experience API) statements, so a Learning Record Store can ingest it.
GET /api/v1/xapi/statements — newest first, authenticated with the same bearer key. Query parameters:
| Parameter | What it does |
|---|---|
limit | How many statements to return (default 100, max 500). |
since | An ISO-8601 timestamp; returns only statements after it, for incremental pulls. |
The response is { "statements": [ … ] }, where each statement follows the xAPI shape — an actor (the learner, as an account on this platform), a verb (viewed / completed / answered / asked / messaged), an object (the activity), and a timestamp. Internal events map to standard xAPI verb IRIs (e.g. a completed lesson → http://adlnet.gov/expapi/verbs/completed).
curl "https://cliplaw.academy/api/v1/xapi/statements?limit=50&since=2026-07-01T00:00:00Z" \
-H "Authorization: Bearer <your-key>"Statements are tenant-scoped to the key. (This is a read/pull surface; SCORM 2004 and a full LTI story are on the roadmap.)