Skip to content

API reference

Base URL: https://kalelm-api.mohanad.xyz/api. Collections (/materials, /transcripts, /sheikhs, …) follow Payload’s REST conventions: ?limit=&page=&depth=&where[field][equals]=&select[field]=true&locale=. Everything below is custom.

POST /search — JSON body.

Field Values Notes
q string required unless mode is keyword
mode keyword · hybrid · semantic default keyword from the API; the site defaults to hybrid
scope transcripts · titles titles is always a literal match
sort relevance · date sorts
type[], sheikhId[], seriesId[] filters
since unix seconds results on or after
language ar (default) · en · ur which language’s material
limit, page 1–50, ≥1 transcript results page over a grouped window, not the whole archive

Response: query, mode, degraded, matchCount, matchDocs, tookMs, scope, page, limit, totalPages, results[], facets. degraded is set to the requested mode when keyword results were served instead. matchCount/matchDocs are null for vector modes on purpose.

GET /related/:id?limit=6 — recordings close in meaning to one recording, judged by its transcript as a whole: the mean of its passage vectors queried against the passage index, no embedder call. results[] in the search result shape (with chapter); empty for a material without a transcript. Cached a day at the edge.

GET /series-summary/:id?language=ar&sheikh=ID — what a series page needs beyond its rows, scoped to one sheikh when sheikh is given (the sheikhs[] list is always the whole series, for the switch): count, duration (seconds), first/last dates, sheikhs[] with counts, chapters[] (book chapters with counts and durations, in the order they were taught: by first lesson) and months[] (YYYY-MM with counts, newest first). SQL, cached five minutes.

Courses come through Payload’s REST: GET /courses?where[status][equals]=published&depth=1 (a course’s levels[].questions[] carry no answer or explanation for a public reader). The reader’s identity is the account hub’s access token, sent as Authorization: Bearer …; the backend verifies it at ${HUB_URL}/api/auth/oauth2/userinfo.

Endpoint Body / params Answer
POST /learn/start { course, level }, bearer { ticket, startedAt, seconds } — the signed start of a timed test; seconds is null for an untimed level
POST /learn/attempt?language= { course, level, answers[], ticket? } — an answer is a 1-based option number, a list of them (multiple), or a string (text); bearer { correct, total, score, passed, late, passMark, level, levelsPassed[], certificate, review[] } with review[i] = { type, correct[], answerText, given, ok, feedback, explanation }; 403 with level when an earlier level is not passed; 400 without the ticket of a timed level; 401 without a valid token; 10 per minute per IP
GET /learn/certificate/:code.pdf?lang= public the certificate as a PDF (A4 landscape), cached an hour; 404 when the code is unknown or revoked
POST /learn/ai { course, level, action: 'questions' | 'summary', count? }, signed-in staff { added } or { description }, written into the course; 503 without ANTHROPIC_API_KEY
GET /learn/me?course= bearer { learner, attempts[], certificates[] }
GET /learn/verify/:code public { code, learnerName, score, issuedAt, course { title, slug, sheikh } } or 404; cached five minutes at the edge

The hub’s own API for sites (hub/): GET /api/data/:site, PUT /api/data/:site/:kind (an object of key → value), DELETE /api/data/:site/:kind/:key, all with the reader’s bearer; see Learning platform and the account hub.

GET /series-index?language=ar — every (series, sheikh) pair with published lessons in that language, one row each: id, title, slug, kind, count, duration, last, sheikh {id, name, slug}, chapters[] {id, title, slug, count} (a book explanation’s chapters in the order that sheikh taught them), newest-updated first. Feeds the lessons landing page. SQL, cached five minutes.

  • GET /facets?type=&language=&sheikh=&series=&year=&hasTranscript=&locale= — counts by sheikh, series, year and transcript presence for the current filters. Cached 5 min per query string.
  • GET /tree?order=type,sheikh,series&path=&language=&offset=&limit= — one level of the browse tree; ?q= searches it. Cached 5 min.

Admin session required for the first two; the rest accept Authorization: Bearer $CRON_SECRET (or an admin session).

Endpoint Body Returns
GET /pipeline/stats counts by status, backlog, last 24 h, active workers, recent failures, settings, local worker
POST /pipeline/control {action: enqueue, limit?} · retryFailed · {requeue, id} · {pause, paused} · startWorker · stopWorker action result
GET /indexing/material?id= signed-in editor one material’s presence in the four indexes (MaterialIndexStatus: cues, segments, sentences, passages, complete, missing)
POST /indexing/reindex {id}, signed-in editor re-pushes the title, the segments (now) and the vectors (background) for one material
POST /pipeline/reindex-vectors {ids?: number[], sinceHours?: 24} (worker secret or admin) {scheduled}; re-pushes sentence and passage vectors in the background
POST /pipeline/claim {worker, limit?} {jobs: [{id, materialId, title, audioUrl, language, duration}], paused}
POST /pipeline/heartbeat {id, stage?} {ok} — false means the job is no longer yours
POST /pipeline/complete {id, cues: [{start, text}], stats?, language?} or {id, error} `{status: done
  • GET /durations/stats, POST /durations/run {kind: youtube|mp3} or {stop: true} — the duration backfill.
  • GET /indexing/stats — progress of the search index builds.

All three panels are admin-only and read by /admin/durations, /admin/indexing, /admin/transcription.

Caller How
Site anonymous REST; read access is public for published content
Admin panel Payload session cookie; roles admin / editor
Workers, cron Authorization: Bearer $CRON_SECRET