Resources
Content API Examples

Content API Examples

Every common Content API task in curl, Node.js, and Python: a retrying request wrapper, key check, classrooms, resource CRUD, incremental sync, idempotent replays, expansion, async game builds, video renders, and webhook receivers.

On this page

Every task below is shown in curl, Node.js 18+ (built-in fetch), and Python 3.9+ (requests). The Node.js and Python examples use the request() wrapper from the first section.

All examples read the same variables:

bash
export TUTORFLOW_API_BASE_URL="https://api.tutorflow.io"
export TUTORFLOW_CONTENT_API_KEY="tf_content_..."          # from Settings > Content API
export CLASSROOM_ID="00000000-0000-4000-8000-000000000010" # from GET /v1/content/classrooms

A request wrapper that follows the retry rules

The wrapper implements the table in Errors:

  • 429: wait Retry-After seconds, then send the same request again.
  • 5xx, network errors, and timeouts: retry with exponential backoff, but only for GET, PATCH, and requests that carry an Idempotency-Key, so a create that did succeed is never repeated.
  • 402: stop and raise. Retrying does not help until billing changes.
  • 409: raise with the code, so the caller reads the resource and decides.
  • 400, 401, 403, 404, 413, 422: raise. Fix the request.

Every error it raises carries the response's Request-Id, so your logs hold the id TutorFlow support needs. requestWithMeta() also returns whether the answer was an idempotent replay (Idempotent-Replayed: true) and the Request-Id of successful calls; request() returns only the body.

JavaScript
// tutorflow.js, Node.js 18+
const BASE_URL = process.env.TUTORFLOW_API_BASE_URL ?? 'https://api.tutorflow.io'
const API_KEY = process.env.TUTORFLOW_CONTENT_API_KEY
const MAX_ATTEMPTS = 5
const REQUEST_TIMEOUT_MS = 30_000
const RETRYABLE_STATUSES = new Set([500, 502, 503, 504])
 
export class TutorFlowError extends Error {
  constructor(status, body, requestId) {
    super(`${body?.error?.message ?? `HTTP ${status}`} (Request-Id: ${requestId ?? 'none'})`)
    this.status = status
    this.code = body?.error?.code ?? null
    this.requestId = body?.error?.requestId ?? requestId ?? null
    this.body = body
  }
}
 
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const backoffMs = (attempt) => Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 250
 
export async function requestWithMeta(method, path, { body, idempotencyKey, headers = {} } = {}) {
  const canRetry = method === 'GET' || method === 'PATCH' || Boolean(idempotencyKey)
 
  for (let attempt = 1; ; attempt++) {
    let response
 
    try {
      response = await fetch(`${BASE_URL}${path}`, {
        method,
        headers: {
          Authorization: `Bearer ${API_KEY}`,
          ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
          ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
          ...headers,
        },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
      })
    } catch (networkError) {
      if (!canRetry || attempt >= MAX_ATTEMPTS) throw networkError
      await sleep(backoffMs(attempt))
      continue
    }
 
    const requestId = response.headers.get('request-id')
 
    if (response.ok) {
      const text = await response.text()
      return {
        body: text ? JSON.parse(text) : null,
        isReplayed: response.headers.get('idempotent-replayed') === 'true',
        requestId,
      }
    }
 
    const errorBody = await response.json().catch(() => null)
 
    if (response.status === 429 && attempt < MAX_ATTEMPTS) {
      await sleep(Number(response.headers.get('retry-after') ?? '1') * 1000)
      continue
    }
 
    if (RETRYABLE_STATUSES.has(response.status) && canRetry && attempt < MAX_ATTEMPTS) {
      await sleep(backoffMs(attempt))
      continue
    }
 
    throw new TutorFlowError(response.status, errorBody, requestId)
  }
}
 
export async function request(method, path, options) {
  const { body } = await requestWithMeta(method, path, options)
  return body
}

Check the key

bash
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/credits" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"

List classrooms

bash
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" | jq '.[] | {id, name}'

Create, update, and delete a module

bash
MODULE_ID="$(curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: module:intro-to-safety:v1" \
  -d '{ "title": "Introduction to safety", "content": "<p>Report every hazard you see.</p>", "metadata": { "externalId": "intro-to-safety" } }' \
  | jq -r '.id')"
 
curl -sS -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules/$MODULE_ID" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Workplace safety basics" }'
 
curl -sS -X DELETE "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules/$MODULE_ID" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"

PATCH /modules/{moduleId} answers with the updated module, as the single read returns it. It also still carries success: true, which is deprecated; do not read it. See Update responses.

Sync changes incrementally

List only what changed since the last run with updatedSince, oldest change first, and page until meta.hasNextPage is false. Store the time the run started, and use it as the next run's updatedSince. Deletions are not listed; take them from the resource.deleted webhook. See Syncing changes.

bash
SINCE="2026-09-29T00:00:00Z"   # the start time of your previous run
 
curl -sS -G "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  --data-urlencode "updatedSince=$SINCE" \
  -d field=updatedAt -d order=ASC -d limit=100 -d page=1 \
  | jq '{ changed: [.data[] | {id, title, updatedAt}], hasNextPage: .meta.hasNextPage }'

The same loop works for courses, videos, slides, tests, games, and simulations.

Tell a replay from a new create

A retried create with the same Idempotency-Key returns the first response with Idempotent-Replayed: true. Use it to avoid recording the same resource twice, or to log that a retry was absorbed.

JavaScript
import { requestWithMeta } from './tutorflow.js'
 
const { body: course, isReplayed, requestId } = await requestWithMeta(
  'POST',
  `/v1/content/classrooms/${process.env.CLASSROOM_ID}/courses`,
  { idempotencyKey: 'course:customer-onboarding:v1', body: { title: 'Customer onboarding', visibility: 'PRIVATE' } },
)
 
console.log(isReplayed ? 'already created' : 'created', course.id, 'Request-Id', requestId)

When a call fails, TutorFlowError carries requestId. Log it, and quote it to TutorFlow support; see Contacting support.

Expand source JSON and wait for the result

The polling here goes through request(), which already waits on 429. A curl loop is in the Quickstart.

JavaScript
import { request, sleep } from './tutorflow.js'
 
const POLL_INTERVAL_MS = 5000
const POLL_DEADLINE_MS = 15 * 60 * 1000
 
const job = await request('POST', '/v1/content/integrations/expansions', {
  idempotencyKey: 'expansion:language-basics-a1:v1',
  body: {
    classroomId: process.env.CLASSROOM_ID,
    requestedOutputs: ['interactive_module', 'summary_video', 'expanded_quiz'],
    payload: {
      language: 'en',
      category: { id: 'language-basics', title: 'Language Basics' },
      level: {
        id: 'level-a1',
        title: 'A1 Foundations',
        lessons: [
          {
            id: 'lesson-greetings',
            type: 'vocabulary',
            title: 'Basic greetings',
            items: [{ term: 'hello', meaning: 'a greeting', example: 'Hello, Mina.' }],
          },
        ],
      },
    },
  },
})
 
const deadline = Date.now() + POLL_DEADLINE_MS
let current = job
 
while (current.status === 'queued' || current.status === 'processing') {
  if (Date.now() > deadline) throw new Error(`Job ${job.id} is still ${current.status}`)
  await sleep(POLL_INTERVAL_MS)
  current = await request('GET', `/v1/content/integrations/expansions/${job.id}`)
}
 
const result = await request('GET', `/v1/content/integrations/expansions/${job.id}/result`)
 
for (const output of result.outputs) {
  console.log(output.outputType, output.status, output.resourceType, output.resourceId)
}

Build a game asynchronously

Create the game, request a brief and a build in async mode, and follow each on GET .../run by its runId. In production, wait for the game.brief.completed and game.build.completed webhooks instead of polling. Prices are in Pricing.

bash
GAMES="$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/games"
AUTH="Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
 
GAME_ID="$(curl -sS -X POST "$GAMES" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: game:fractions-run:v1" \
  -d '{ "title": "Fractions run", "visibility": "PRIVATE", "metadata": { "topic": "Comparing fractions", "audience": "Grade 5", "language": "en" } }' \
  | jq -r '.id')"
 
wait_for_run() {
  until [ "$(curl -sS "$GAMES/$GAME_ID/run" -H "$AUTH" | jq -r --arg run "$1" '.last.runId == $run')" = "true" ]; do sleep "$2"; done
  curl -sS "$GAMES/$GAME_ID/run" -H "$AUTH" | jq '.last'
}
 
BRIEF_RUN_ID="$(curl -sS -X POST "$GAMES/$GAME_ID/brief" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Prefer: respond-async" -H "Idempotency-Key: game:$GAME_ID:brief:v1" -d '{}' | jq -r '.runId')"
wait_for_run "$BRIEF_RUN_ID" 10
 
BUILD_RUN_ID="$(curl -sS -X POST "$GAMES/$GAME_ID/build" -H "$AUTH" \
  -H "Prefer: respond-async" -H "Idempotency-Key: game:$GAME_ID:build:v1" | jq -r '.runId')"
wait_for_run "$BUILD_RUN_ID" 20

Simulations use the same calls under /simulations. last on GET .../run shows the run that finished most recently and is kept for 1 hour, so poll within that hour or rely on the webhooks.

Render a video and wait for it

Render a video once its scenes have a script. Send generateMissingNarration: true to narrate scenes that have none in the same request (1 AI Credit per scene); without it a scene without narration returns 400 content_video_narration_missing. See Video Rendering.

bash
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Idempotency-Key: video:$VIDEO_ID:render:v1" \
  -H "Content-Type: application/json" \
  -d '{ "generateMissingNarration": true }'
 
until [ "$(curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" | jq -r '.renderStatus')" != "RENDERING" ]; do sleep 15; done
 
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" | jq '{renderStatus, videoUrl, videoUrlExpiresAt}'

A render that is already running answers 409 content_video_render_in_progress; the wrapper raises it with code set, so check error.code and poll the status instead of starting again.

Receive webhooks

Complete receivers that verify X-Content-Integration-Signature-V2 against the raw body, in Express, Fastify, and Flask, are in Verify the signature. Register the endpoint with:

bash
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/webhooks" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/tutorflow/webhooks", "events": ["content.completed", "content.failed", "game.build.completed", "game.build.failed", "video.render.completed", "video.render.failed"] }'

This needs a key with webhooks:manage and no classroom allowlist.

Was this page helpful?