API Contracts
Part of: Pickatale Master Build Wiki | Version: v2.0 | Last Updated: 2026-04-19
4. API Contracts
Source: (B) + verified code audit | Status: Confirmed (marked UNVERIFIED where not code-verified)
Auth Header Rules
- Internal service-to-service calls:
X-Internal-Keyheader (value from env var β never hardcoded) - External client calls:
Authorization: Bearer <JWT>(httpOnly cookie) - Timeout: 5s for all internal fire-and-forget calls
4.1 Reader App (Server :3125)
POST /api/session/end
Ends a reading session and fires telemetry.
Input:
{
"learner_id": "uuid-string",
"session_id": "uuid-string",
"book_id": "string",
"client_ts": 1713470400000
}
Output:
{
"ok": true,
"events_received": 42
}
Errors:
- 400: missing required fields
- 401: invalid JWT
GET /api/recommendations
Returns personalised book picks for the learner.
Input (query): ?learner_id=uuid
Output:
{
"books": [
{
"permanentId": "14760-1",
"title": "string",
"author": "string",
"coverUrl": "string",
"reason": "string"
}
]
}
Errors:
- 404: learner not found β falls back to FK-level filtered catalogue
GET /api/internal/learner/:id
Used by Learner Bot to pull reader stats.
Output:
{
"reading_level": 3.5,
"books_read": 12,
"total_miles": 4.2
}
Auth: X-Internal-Key
4.2 Telemetry Service (:3110)
POST /api/events
Ingest a single telemetry event.
Input:
{
"event_id": "uuid",
"learner_id": "uuid",
"session_id": "uuid",
"event_type": "page_turned | word_tapped | book_opened | session_ended | quiz_answered | book_abandoned",
"book_id": "string",
"page_number": 3,
"word": "magnificent",
"time_on_page_ms": 42000,
"client_ts": 1713470400000
}
Output: { "ok": true }
Errors:
- 400: invalid event_type
- 409: duplicate event_id (idempotent β return 200)
POST /api/session/end
Aggregates session and fires to Learner Bot.
Input:
{
"session_id": "uuid",
"learner_id": "uuid"
}
Output: { "ok": true }
Side effects:
- POST to Learner Bot
/api/v1/telemetry/vocab-taps(5s timeout) - POST to Learner Bot
/api/v1/telemetry/session-summary(5s timeout)
4.3 Learner Bot (:3120)
All routes require X-Internal-Key header.
GET /api/v1/bot/:learner_id/status
Full learner status.
Output:
{
"reading_level": 3.5,
"books_read": 12,
"total_miles": 4.2,
"readingLevelHistory": [{"level": 3.0, "date": "2026-04-01"}],
"booksCompleted": ["14760-1"],
"comprehensionTrend": [{"date": "2026-04-18", "score": 78}],
"latestReport": "string",
"vocab_gaps": [{"word": "magnificent", "tap_count": 3}],
"curriculum_state": {"objective_id": "string", "value": "assessment", "mastery": 0.67}
}
Errors: 404 if learner not found
POST /api/v1/bot/:learner_id/quiz-result
Store assessment result and trigger bot run.
Input:
{
"bookId": "string",
"fkLevel": 3.5,
"scorePct": 78.5,
"questionsTotal": 5,
"questionsCorrect": 4,
"levelRecommendation": "hold"
}
levelRecommendation enum: drop | hold | raise
Output: { "ok": true }
Side effect: triggers setImmediate(() => runBotForLearner(learnerId))
POST /api/v1/telemetry/vocab-taps
Upserts vocabulary gap counts.
Input:
{
"learner_id": "uuid",
"session_id": "uuid",
"words": [
{"word": "magnificent", "count": 2},
{"word": "photosynthesis", "count": 1}
]
}
Output: { "ok": true, "upserted": 2 }
POST /api/v1/telemetry/session-summary
Stores reading pattern and engagement signals.
Input:
{
"learner_id": "uuid",
"session_id": "uuid",
"pages_read": 12,
"total_pages": 15,
"avg_time_per_page_ms": 48600,
"slow_pages": [3, 5, 7]
}
Logic:
- confidence = 0.90 if pages_read/total_pages > 0.8 else 0.60
- engagement_signal
{signal: "struggling"}stored if slow_pages.length > 3
Output: { "ok": true }
4.4 Adaptive Content Engine (:3119)
POST /api/v1/level-page
Input:
{
"bookId": "string",
"pageNumber": 3,
"text": "string",
"targetFkLevel": 3.5
}
Output:
{
"leveledText": "string",
"actualFkScore": 3.4,
"cached": true
}
Rules:
- Text under 30 words: returned unchanged
- Cache key: book_id + page_number + rounded_level (0.5 increments) + source_text_hash
- Retry if |actualFkScore - targetFkLevel| > 0.8
- Provider: GPT-4o (enabled). DeepL, Google Translate: unavailable (no keys)
Errors: 400 missing fields, 503 GPT unavailable
POST /api/v1/adapt
Full book adaptation (uses target_fk_grade decimal).
Input:
{
"book_id": "string",
"target_fk_grade": 3.50
}
Output:
{
"adapted_pages": [{"page_number": 1, "text": "string"}],
"cached": false
}
Cache tolerance: Β±0.1 on target_fk_grade
4.5 Curriculum Mapper β REST API (Phase 2 β MUST BUILD)
Status: UNVERIFIED β tRPC only currently; REST endpoint does not exist yet
GET /api/v1/objectives
Query params: ?territory=Turkey&year_level=3&subject=English
Output:
{
"objectives": [
{
"id": 1,
"title": "string",
"description": "string",
"difficulty": 3,
"vocabulary": ["word1", "word2"],
"story_potential_score": 0.87
}
]
}
Auth: X-Internal-Key
4.5b Curriculum Mapper β generateIntelligence (tRPC β LIVE)
Status: β
LIVE at cm.readingtester.com β tRPC mutation
Procedure: curriculum.generateIntelligence
Auth: Session cookie (authenticated CM user)
Input
{
"objectiveId": 16470,
"type": "book_brief" | "teacher_brief" | "html_activity" | "video_script" | "remotion_script"
}
Output
{ "type": "<input type>", "content": "<string β JSON or HTML>" }
type: "book_brief"
Generates a full lesson book (10β12 pages, Usborne-style illustrated non-fiction) for the objective. Stores result in lesson_books table. Content is a JSON string matching the Lesson Book Pages Schema.
type: "teacher_brief"
Generates a structured teacher-facing brief for the objective: learning goals, key vocabulary, discussion prompts, differentiation tips. Returns Markdown string.
type: "html_activity"
Generates a self-contained interactive HTML activity (quiz, drag-drop, or matching) for the objective. Returns a full HTML document string.
type: "video_script"
Generates a Pictory-format 40-second video script for the objective:
{
"hook": "~16 words, 0β8s",
"mainPoints": ["~16 words each, 8β32s (3 items)"],
"conclusion": "~16 words, 32β40s",
"voiceoverTone": "string",
"suggestedVisuals": ["string (4 items)"],
"backgroundMusic": "string",
"fullScript": "~80 words complete narration"
}
type: "remotion_script"
No LLM call β pure data transformation from the stored lesson book. Requires a generated lesson book (type: "book_brief") for this objective first.
Transforms the lesson book pages JSON into a Remotion-compatible scene array ready for Claude Code + Remotion video generation.
{
"title": "string",
"subtitle": "string",
"learningObjective": "string",
"bloomsLevel": "string",
"targetAge": "string",
"readingLevel": "string",
"totalDurationSecs": 62,
"voiceoverTone": "warm, engaging, age-appropriate β like a friendly teacher",
"backgroundMusic": "gentle upbeat educational loop",
"scenes": [
{
"id": "scene_1",
"pageNumber": 1,
"type": "cover" | "objective" | "vocabulary" | "content" | "realworld" | "recap" | "reflect",
"durationSecs": 5,
"narration": "string β 40-second voiceover script for this scene",
"visual": "string β Usborne-style image prompt (150+ words)",
"textOverlay": "string β heading shown on screen",
"animationType": "fade-in-scale" | "stagger-words" | "slide-up-list" | "fade-in",
"extra": { "videoScene": "...", "words": [], "points": [], "questions": [] }
}
]
}
Duration map by page type:
| Page type | durationSecs |
|---|---|
| cover | 5 |
| objective | 4 |
| vocabulary | 6 |
| content | 8 |
| realworld | 6 |
| recap | 5 |
| reflect | 4 |
| other | 6 (default) |
Error: Returns NOT_FOUND if no lesson book exists for the objective.
Lesson Book Pages Schema
Stored in lesson_books.pages (JSON column). Each page in the pages array:
{
"pageNumber": 1,
"type": "cover" | "objective" | "vocabulary" | "content" | "realworld" | "recap" | "reflect" | "teacher",
"heading": "string",
"bodyText": "string",
"imagePrompt": "string β 150+ words, Usborne-style, no embedded text",
"voiceoverScript": "string β ~40s narration",
"voiceoverTone": "string",
"videoScene": "string β opening visual for video"
}
Note: teacher type pages are excluded from remotion_script output.
4.6 Orchestrator (:3121 β Phase 2 β MUST BUILD)
Status: UNVERIFIED β service does not exist yet
POST /api/v1/orchestrate/:learner_id
Trigger full gap-to-content loop for one learner.
Input: none (learner_id in path)
Output:
{
"status": "triggered",
"objective_id": 1,
"content_selected": "14760-1",
"content_source": "content_service | decodables | pickatale_create",
"fk_level_target": 3.5,
"assessment_queued": true
}
GET /api/v1/orchestrate/:learner_id/status
Check last orchestration result.
Output:
{
"last_run": "2026-04-18T04:00:00Z",
"objective_id": 1,
"mastery_result": "hold",
"next_action": "re-serve with different approach"
}
4.7 Assessment Bot (Phase 2 β MUST BUILD)
Status: UNVERIFIED β does not exist yet
POST /api/v1/assess/generate
Generate comprehension questions for a book.
Input:
{
"book_id": "string",
"learner_id": "uuid",
"page_texts": ["page 1 text", "page 2 text"]
}
Output:
{
"questions": [
{
"id": 1,
"question": "What did Sofia find in the garden?",
"options": ["A flower", "A key", "A map", "A cat"],
"correct_index": 1
}
]
}
Rules: 3β5 multiple choice questions. GPT-4o generates from page_texts.
POST /api/v1/assess/result
Score child's answers.
Input:
{
"learner_id": "uuid",
"book_id": "string",
"answers": [{"question_id": 1, "selected_index": 1}]
}
Output:
{
"score_pct": 80.0,
"questions_total": 5,
"questions_correct": 4,
"level_recommendation": "hold"
}
Pass threshold: 65%. Below β drop. 65β85% β hold. Above 85% β raise.
4.8 Teacher Portal (:3116)
GET /api/v1/class/:class_id/students
List all students in a class with enriched learner data.
Auth: JWT (teacher role)
Output:
{
"students": [
{
"id": 1,
"name": "Sofia",
"username": "sofia001",
"learner_service_id": "uuid",
"reading_level": 3.5,
"books_read": 12,
"total_miles": 4.2,
"comprehension_trend": [78, 82, 85],
"attention_flag": false,
"latest_report": "string"
}
]
}
POST /api/v1/class/:class_id/students/import
Bulk import students from CSV.
Input: multipart form-data, file field roster
CSV columns: name, year_level
Output:
{
"imported": 28,
"students": [
{"name": "Sofia", "username": "sofia001", "pin": "4821"}
]
}
Constraint: PIN shown once in this response only. Not stored in plaintext.
4.9 Account Center (SSO)
GET /api/auth/session
Verify uc_session cookie and return role + identity.
Auth: uc_session cookie (httpOnly, domain: .readingtester.com)
Output:
{
"valid": true,
"user_id": "uuid",
"role": "child | teacher | parent | admin",
"name": "string",
"email": "string"
}
Errors: 401 if cookie invalid/expired
POST /api/auth/login
Input:
{
"email": "string",
"password": "string"
}
Output: Sets uc_session cookie. Returns { "ok": true, "role": "teacher" }
Errors:
- 401: wrong credentials
- 429: rate limit exceeded
25. New API Contracts (Identity, Billing, Roster)
Source: (D) | Status: Confirmed as spec
25.1 Account Center β Registration
POST /api/auth/register
Input:
{
"name": "string",
"email": "string",
"password": "string",
"role": "teacher | parent | school_admin",
"school_name": "string (optional)"
}
Output: { "ok": true, "user_id": "uuid", "state": "pending_verification" }
Errors: 409 duplicate email | 422 validation | 503 SendGrid failure (non-blocking)
POST /api/auth/verify-email
Input: { "token": "string" }
Output: { "ok": true } + sets uc_session cookie
Errors: 410 expired | 404 not found
POST /api/auth/resend-verification
Input: { "email": "string" }
Output: { "ok": true } (always β no account enumeration)
Rate limit: 3/hour per email
25.2 Account Center β Password
POST /api/auth/forgot-password
Input: { "email": "string" }
Output: { "ok": true } (always)
Side effect: sends PASSWORD_RESET email if email exists
POST /api/auth/reset-password
Input: { "token": "string", "password": "string" }
Output: { "ok": true }
Errors: 410 expired | 422 weak password
25.3 Subscriptions
GET /api/billing/subscription
Auth: uc_session (teacher/parent/school_admin role) Output:
{
"state": "trialing",
"tier": "trial",
"trial_ends_at": "2026-05-02T00:00:00Z",
"current_period_end": null,
"features": ["full_library", "learner_bot", "reports", "interventions"]
}
POST /api/billing/checkout
Creates Stripe checkout session.
Input: { "plan": "parent_monthly | parent_annual | school_annual", "student_count": 30 }
Output: { "checkout_url": "https://checkout.stripe.com/..." }
POST /api/billing/webhook
Stripe webhook endpoint (signature verified).
Events handled: invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted, customer.subscription.updated
Errors: 400 invalid signature | 200 always (even if unhandled event β Stripe requires 200)
25.4 Entitlement Check
GET /api/billing/entitlement
Auth: X-Internal-Key (service-to-service)
Query: ?user_id=uuid
Output:
{
"allowed": true,
"tier": "full",
"expires_at": "2026-05-02T00:00:00Z",
"features": ["full_library", "learner_bot", "reports"]
}
Rule: Check order: entitlement_grants first, then subscriptions. Return most permissive.
25.5 Teacher Portal β Notifications
GET /api/v1/notifications
Auth: JWT (teacher) Output:
{
"notifications": [
{
"id": 1,
"type": "parent_claim_request | low_score | inactive | no_placement | child_locked_pin",
"child_name": "string",
"message": "string",
"created_at": "ISO",
"action_url": "string",
"read": false
}
]
}
POST /api/v1/notifications/:id/read
Marks notification read.
POST /api/v1/parent-claims/:claim_id/approve
Approves parent claim (flow 20.1).
Output: { "ok": true } + sets students.parent_id, sends PARENT_CLAIM_APPROVED email
39. API Consistency Rules
Source: (D) | Status: Confirmed as spec. Every API endpoint MUST conform.**
39.1 Authentication
All protected endpoints must validate the uc_session cookie via Account Center session middleware:
Middleware: requireSession()
1. Read uc_session cookie
2. GET /api/auth/session (Account Center, X-Internal-Key, 2s timeout)
3. If 401/timeout β return 401 to client
4. Attach {user_id, role, school_id, entitlement_tier} to request context
Service-to-service calls (internal) must send:
Header: X-Internal-Key: (never hardcoded)
No endpoint may accept both uc_session and X-Internal-Key β choose one per endpoint. Public endpoints (login, register, verify) use neither.
39.2 Error Format
Every error response must use this exact shape:
{
"error": "snake_case_error_code",
"message": "Human readable string (for logging, not display)",
"details": {}
}
| HTTP Code | When to use |
|---|---|
| 400 | Malformed request body |
| 401 | Not authenticated |
| 403 | Authenticated but not authorised |
| 404 | Resource not found |
| 409 | Conflict (duplicate, state clash) |
| 410 | Gone (token expired/used) |
| 422 | Validation failure |
| 423 | Locked (account locked, too many attempts) |
| 429 | Rate limited |
| 500 | Server error (never expose internals) |
Never return HTML error pages for API routes. Always JSON.
39.3 Pagination
List endpoints that can return more than 20 rows must use cursor pagination:
GET /api/v1/classes/:id/students?cursor=<opaque>&limit=50
Response:
{
"data": [...],
"nextCursor": "base64_encoded_id_or_null",
"hasMore": true
}
Default limit: 50. Max limit: 200. Reject requests with limit > 200 β 422.
39.4 Idempotency
Endpoints that create resources or trigger external effects must support the Idempotency-Key header:
Header: Idempotency-Key: <client-generated UUID>
Behaviour:
- Hash key, check
idempotency_keystable (ttl: 24h) - If found and result cached β return cached response immediately
- If found and in-flight β return 409
{error: "request_in_flight"} - If not found β execute, store result, return
Required on: POST /api/auth/register, POST /api/billing/checkout, POST /api/v1/classes, POST /api/v1/classes/:id/students, POST /api/v1/classes/:id/students/import
CREATE TABLE idempotency_keys (
key_hash VARCHAR(64) PRIMARY KEY,
status ENUM('in_flight','complete') NOT NULL,
response JSON NULL,
created_at DATETIME NOT NULL DEFAULT NOW(),
expires_at DATETIME NOT NULL
);
39.5 Rate Limiting
All endpoints: express-rate-limit middleware.
| Endpoint group | Limit |
|---|---|
| Auth (login, register, forgot-password) | 5 req / 15 min / IP |
| Child login | 10 req / 15 min / IP |
| API (authenticated) | 300 req / min / user_id |
| Admin APIs | 60 req / min / user_id |
| Webhooks (Stripe) | No rate limit (verified by signature) |
On 429: return Retry-After header with seconds until reset.