JSON in, JSON out. Errors: { "error": { "code": string, "message": string } }. Times in seconds (floats). Every request body is validated with Zod before any side effect. Machine-readable version: openapi.yaml.
Organizer = request carries a valid pravaha_org cookie (SECURITY.md). Everything else is public.
- Auth: public
- Request:
{ "passcode": string } - Response:
204, setspravaha_org(HttpOnly, Secure, SameSite=Lax, 7 days) - Errors:
400bad body ·401wrong passcode (constant-time compare) - Rate limit: Vercel defaults; passcode is long and random (
SETUP.md)
- Clears the cookie.
204.
- Auth: organizer
- Request:
{ "title": string(1–140), "speaker"?: string(≤80), "rightsConfirmed": true } - Effect: inserts
lecturesrow (processing,unlisted,rights_confirmed_at = now()) with server-chosenpublic_id = pravaha/<id> - Response:
201 { "lecture": Lecture, "uploadPreset": string } - Errors:
400(incl.rightsConfirmednottrue) ·401
- Auth: organizer for the organizer preset; none for the trial preset, which is signed only for a trial row that is still
processingand under 30 minutes old (seePOST /api/trials). Each preset only works for its own kind of session - Contract:
CldUploadWidget'ssignatureEndpoint:{ "paramsToSign": {...} }in,{ "signature" }out - Policy (
src/lib/upload-policy.ts): only the keyspublic_id,upload_preset,timestamp,source; preset must be ours;public_idmust be an existingprocessinglecture; timestamp within 10 min. The AI params (auto_transcription,auto_chaptering,notification_url) live in the signed upload preset, so a client can't add or change them. - Errors:
400unsignable / unknown lecture ·401
- Auth: Cloudinary signature —
X-Cld-Signature+X-Cld-Timestampover the raw body, checked before parsing; timestamp older than 2 h rejected - Handles:
info_kind: "auto_transcription"→complete: fetch{public_id}.transcript, (fetch chapters if available), build segments, replace in one transaction, statusready·failed: statustranscript_failed. Other notification types:200no-op. - Response:
{ "received": true } - Errors:
401bad signature (no DB write) ·200for unknownpublic_id(logged — nothing to retry) ·500only if the DB write fails, so Cloudinary retries - Idempotency: delete-then-insert in a transaction; retries produce the same rows
- Auth: public (unlisted = reachable by direct link); used by the Studio to poll status every 5 s
- Response:
{ id, title, speaker, status, visibility, durationS, publicId, createdAt } - Errors:
404
- Auth: organizer
- Request:
{ "visibility"?: "public" | "unlisted", "title"?: string, "speaker"?: string } - Errors:
400(incl. publishing a session that isn'tready) ·401·404
- Auth: public →
ready+publiconly (the demo Studio); organizer → every session except trials, with status (the Studio polls this every 5 s while anything is processing) - Response:
[{ id, title, speaker, status, visibility, durationS, publicId, createdAt, trialExpiresAt, moments, chapters, hasStudyPack }]: what the pipeline built for each session
- Auth: public. Searches
ready+publicsessions; withlectureId, that one session (unlisted allowed — you have the link) - Validation:
q2–200 chars - Matching: every word, web-search style (
"phrases",-exclusions,OR). If nothing matches every word and no search syntax was used, the closest moments (any of the words, best-ranked, up to 10) are returned withexact: false, so a full question still finds the relevant moments - Response:
{ "exact": boolean, "results": [{ segmentId, lectureId, publicId, title, speaker, startS, endS, text, chapterTitle, snippet: [{ text, hit }] }] }—ts_headlinemarks matches with sentinel characters that are split into plain-text parts (src/lib/highlight.ts), so transcript text is never rendered as HTML - Errors:
400
- Auth: public, rate-limited: 60/hour per IP-hash, 500/day global →
429withRetry-After - Request:
{ "question": string(3–300), "lectureId"?: uuid } - Response:
{ "status": "answered" | "not_found" | "fallback", "answer": "Overfitting is when … [1] … [2]", "citations": [{ "n": 1, "segmentId": 812, "lectureId": "…", "title": "…", "speaker": "…", "startS": 754.2, "endS": 764.9, "text": "…", "momentUrl": "https://res.cloudinary.com/…" }] }not_found: no retrieved segments, or the model cited nothing valid →answeris a fixed "not covered in this library" message.fallback: every model in the AI chain errored or timed out (or noGEMINI_API_KEY) →answeris null,citationsare the top retrieved segments (Find results as clips — NFR4). - Errors:
400·429
Every endpoint maps to an FR in PRD.md; there is no endpoint without one.
- Auth: organizer ·
maxDuration60 s - Effect: (re)generates the session's Study Pack (one
generateJsoncall, validated byvalidateStudyPack) and stores it - Response:
StudyPack{ summary[], concepts[{name,segmentId,startS,endS}], quiz[{question,options[4],correctIndex,explanation,segmentId,startS,endS}], highlights[{segmentId,startS,endS,label}] } - Errors:
400not ready / too short ·401·404·502AI failed ·503noGEMINI_API_KEY - Study Packs are also generated automatically after ingest (
after()in the webhook)
- Auth: public · Request:
{ "segmentId": int, "kind": "open" | "share" }· Response:204·404unknown segment - Anonymous Moment analytics for organizer Insights
- Auth: organizer, or anyone while the demo Studio is on (
STUDIO_DEMO, defaulton;401whenoff). Visitors get the same aggregates minus questions that look like links, e-mail addresses or phone numbers, and Moments only from published sessions · Response:{ totals: { questions, answeredRate, shares }, gaps[{question,times,lastAsked}], topQuestions[{question,times,answered}], topMoments[{segmentId,lectureId,title,startS,text,opens,shares}], daily[{day,answered,unanswered}] (last 14 days, India time), topSessions[{lectureId,title,answers}] (sessions cited by answers), feedback }(last 30 days)
- Auth: same as
GET /api/insights(visitors get the same filtered aggregates) · Response:text/csv; charset=utf-8with a byte-order mark (Excel opens non-Latin questions correctly),Content-Disposition: attachment; filename="pravaha-<report>-<date>.csv",Cache-Control: private, no-store - Each list in full (up to 1000 rows), not the top 10 the Studio shows. Columns:
gapsquestion, times_asked, last_asked ·questionsquestion, times_asked, times_answered, answered_percent ·momentssession, at, start_seconds, quote, opens, shares, url ·dailydate, answered, not_answered (14 days) ·sessionssession, answers_cited_in, url - Learner-typed cells that start with
=,+,-,@, a tab or a carriage return get a leading', so a question can't run as a formula in a spreadsheet (CSV injection) - Errors:
400 invalid_report·401as above
- Response adds
reel: { url, durationS, clips } | null, the Answer Reel (Phase 12) - Every Ask is logged to
ask_logafter the response (after()), with no identity - Response adds
followUps: string[](≤ 3,answeredonly): suggested next questions, cleaned bycleanFollowUps()(trimmed, ≤ 120 chars, de-duplicated against each other and the question). They are links to a new Ask, never citations - Response adds
answerId: string | null(answeredonly): the answer is stored exactly as shown (answerstable) and can be shared at/a/{answerId}, which renders it without re-running the model.nullif it couldn't be stored; the answer is still returned - Streaming: with
Accept: application/x-ndjsonthe same Ask is streamed as newline-delimited JSON events:{ "type": "retrieved", "moments", "sessions": [title…] }→{ "type": "writing" }(only when the model is called) →{ "type": "result", …the JSON body above }, or{ "type": "error" }if retrieval fails. Validation is unchanged: the answer is sent only once it is complete and its citations are checked. Without that header the response is the plain JSON body (eval script, API clients). Rate limiting and400/415/429happen before the stream starts
- Request:
{ "helpful": boolean }. Public, anonymous: no identity or IP is stored; the browser remembers its vote per answer - Response:
204;404for an unknown answer;400for an invalid body - Insights reports the share of answers rated helpful over the last 30 days
- Auth: public. Limits: 2 per network (salted IP hash) and
TRIALS_PER_DAYin total (default 5) per 24 h;TRIALS_PER_DAY=0turns trials off - Request:
{ "title": string(1–140), "rightsConfirmed": true } - Effect: inserts an unlisted
lecturesrow withtrial_expires_at = now() + 24 hand returns the trial preset (CLOUDINARY_TRIAL_PRESET, defaultpravaha_trial), which keeps only the first 60 s of the upload. The browser then uploads throughPOST /api/upload-signaturelike the Studio. Expired trials are deleted with their Cloudinary assets after each trial request - Response:
201 { "lecture": Lecture, "uploadPreset": string } - Errors:
400·429trial_limit/trials_full·503trials_disabled - A trial is never listed, searched or asked across the library.
POST /api/askwith itslectureIdworks; those questions are not logged to Insights and their answers aren't stored as share links (answerId: null)
- Scopes the Ask to one session (the Watch page's Ask this session). If no keyword matches ("What is this video about?"), retrieval falls back to moments sampled evenly across the session, so the model can summarise it; it still refuses when they don't answer the question
- Auth: public,
Cache-Control: no-store - Response:
200 { "status": "ok", "checks": { "database": { "ok", "ms" }, "ai": { "configured", "providers" }, "trials": { "enabled" }, "demoStudio": { "enabled" } }, "version": "<commit>" };503with"status": "degraded"when the database is unreachable (3 s timeout). No secrets or config values
- Auth: public, shares Ask's rate limit (
429+Retry-After) ·maxDuration45 s - Request:
{ "topic": string(3–200) } - Response:
{ "status": "built", "path": { id, topic, title, steps[{ n, stepTitle, why, segmentId, lectureId, title, speaker, startS, endS, … }], reel: { url, durationS, clips } } }or{ "status": "not_found" }when the library can't support at least 3 on-topic steps - Errors:
400·429·502AI failed ·503no AI key - Courses are stored (
learning_paths) and open at/p/[id].
Adds checks.aiLive.models[{ model, ok, ms, error? }], one tiny real call per model. It spends tokens, so it shares Ask's rate limit.
Retrieval now rewrites a question into the library's vocabulary when it is in another script or keyword retrieval is thin. The stream gains an { "type": "understanding" } event, and retrieved carries expanded: boolean.
- Auth: public
- Response:
[{ key, name, sessions }]: every concept the published library teaches, most-taught first
- Auth: public
- Response:
{ key, name, moments: [{ lectureId, title, speaker, startS, endS, text, related }] }(up to 5, one per session;relatedis true for a session that teaches it without naming it a key concept) ·404unknown key
- Auth: organizer cookie (
401otherwise) ·maxDuration60 s POSTwrites tags and contextual metadata onto every session's Cloudinary asset, then returns{ synced, skipped, assets }read back with the Search API.GETreturns just{ assets: [{ publicId, tags, context }] }
- Auth: public (the unguessable session id is the access, like the Watch page)
- Query:
format=mdreturns Markdown (text/markdown); adddownload=1for aContent-Disposition: attachment.mdfile. Withoutformat, JSON - Response (JSON):
{ title, speaker, durationS, watchUrl, summary[], concepts[{ name, t, label, url }], chapters[{ title, t, label, url }], quiz[{ question, answer, explanation, t, label, url }] }; everyurlopens the session at that second - Errors:
404the session isn't ready, doesn't exist, or has no summary, chapters or quiz yet
- Auth: public,
Cache-Control: no-store - Response:
{ level: "operational"|"degraded"|"outage", reasons[], version, checkedAt, database: { ok, ms }, ai: { providers[], models: [{ model, ok, ms, kind? }] | null, probedAt }, cloudinary: { used, limit, pct, level, transformations, storage, bandwidth, reportedOn } | null, library: { sessions, moments, studyPacks, questionsAsked } | null } 503only when the database is unreachable. AI probes are cached 5 minutes and Cloudinary usage 10 minutes per server instance.kindisquota,overloadedorerror, never a raw provider message. No secrets or config values
An identical question (normalised: case, whitespace and trailing punctuation ignored) in the same scope (the whole library, or the same lectureId) is answered from the stored answer when it is under 24 hours old and no session has been added, published or changed since. The response is the stored one plus "cached": true, with the same answerId. No AI call is made and the rate limit still applies.