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


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:

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:

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:

POST /api/session/end

Aggregates session and fires to Learner Bot.

Input:

{
  "session_id": "uuid",
  "learner_id": "uuid"
}

Output: { "ok": true } Side effects:

  1. POST to Learner Bot /api/v1/telemetry/vocab-taps (5s timeout)
  2. 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:

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:

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 typedurationSecs
cover5
objective4
vocabulary6
content8
realworld6
recap5
reflect4
other6 (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:



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:

  1. Hash key, check idempotency_keys table (ttl: 24h)
  2. If found and result cached β†’ return cached response immediately
  3. If found and in-flight β†’ return 409 {error: "request_in_flight"}
  4. 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.