Two clients wrap the Content API so an integration does not have to write the plumbing itself. Both are generated from the same OpenAPI document the API reference publishes, so every route, request body, and response is typed, and both add the same helpers:
- Retries on
429afterRetry-After, and on5xxor dropped connections when repeating the request is safe. - Idempotency keys that stay the same across retries, so a retried
POSTis answered with the first result. - Typed errors with
code,status,requestId,retryAfterSeconds, andcurrentETagfrom the error envelope. - Pagination over both list shapes: page-numbered lists and cursor lists.
- Waiting for runs: generation runs, game and simulation builds, and video renders.
- Webhook verification of
X-Content-Integration-Signature-V2, including the secret rotation grace period, withlivemodeon every event.
| Language | Package | Built on | Source |
|---|---|---|---|
| TypeScript and JavaScript (Node.js 20+) | @tutorflow/content-api-sdk | openapi-fetch | packages/content-api-sdk |
| Python 3.11+ | tutorflow-content | httpx | sdks/python |
The SDKs are not on npm or PyPI yet. Install them from source as shown below. Until they are published, their interfaces may still change between versions.
Install from source
git clone https://github.com/geek-haus/tutorflow-monorepo.git
cd tutorflow-monorepo
pnpm install
pnpm --filter @tutorflow/content-api-sdk build
# In your project:
pnpm add /path/to/tutorflow-monorepo/packages/content-api-sdkgit clone https://github.com/geek-haus/tutorflow-monorepo.git
pip install ./tutorflow-monorepo/sdks/pythonMake a call
Keep the key on your server. Both clients send it as a bearer token and refuse a value that does not start with tf_content_. A test key (tf_content_test_...) works the same way against the sandbox classroom.
import { createClient, unwrap } from '@tutorflow/content-api-sdk'
const client = createClient({ apiKey: process.env.TUTORFLOW_CONTENT_API_KEY! })
// unwrap() returns the data, or throws a ContentApiError.
const classrooms = unwrap(await client.GET('/v1/content/classrooms'))import os
from tutorflow_content import create_client, unwrap
from tutorflow_content.generated.api.content_classrooms import (
content_integration_classroom_controller_list_classrooms as list_classrooms,
)
client = create_client(os.environ["TUTORFLOW_CONTENT_API_KEY"])
# unwrap() returns the parsed model, or raises ContentApiError.
classrooms = unwrap(list_classrooms.sync_detailed(client=client))Generate a course and wait for it
import { waitForRun, withIdempotencyKey } from '@tutorflow/content-api-sdk'
const accepted = unwrap(
await client.POST(
'/v1/content/classrooms/{classroomId}/courses/generate',
withIdempotencyKey({
params: { path: { classroomId } },
body: { prompt: 'Fractions for Grade 5', lessonCount: 6, language: 'en' },
}),
),
)
const run = await waitForRun(client, { type: 'generation', classroomId, runId: accepted.runId })
if (run.status === 'failed') throw new Error(run.error ?? 'Generation failed')from tutorflow_content import new_idempotency_key, wait_for_run
from tutorflow_content.generated.api.content_generation import (
content_generation_controller_generate_course as generate_course,
)
from tutorflow_content.generated.models import ContentGenerateCourseRequest
accepted = unwrap(
generate_course.sync_detailed(
classroom_id,
client=client,
body=ContentGenerateCourseRequest(topic="Fractions for Grade 5", lesson_count=6, language="en"),
idempotency_key=new_idempotency_key(),
)
)
run = wait_for_run(client, classroom_id=classroom_id, run_id=accepted.run_id)
if str(run.status) == "failed":
raise RuntimeError(run.error)Waiting does not throw when the run failed; check the status it returns. The same call waits for a game or simulation build (type: 'build', or wait_for_build in Python) and a video render (type: 'render', or wait_for_render). A webhook tells you the same without polling, so prefer one wherever you can receive it.
List every item
paginate follows meta.page on page-numbered lists and meta.nextCursor on cursor lists. Keep the other filters the same on every call.
import { paginate } from '@tutorflow/content-api-sdk'
for await (const course of paginate((next) =>
client.GET('/v1/content/classrooms/{classroomId}/courses', {
params: { path: { classroomId }, query: { ...next, limit: 100 } },
}),
)) {
console.log(course.id, course.title)
}from tutorflow_content import paginate
from tutorflow_content.generated.api.content_courses import content_resource_course_controller_list as list_courses
for course in paginate(lambda **next: list_courses.sync_detailed(classroom_id, client=client, limit=100, **next)):
print(course.id, course.title)Read and write with ETags
The TypeScript SDK has getIfChanged and updateIfMatch. getIfChanged sends If-None-Match together with Cache-Control: max-age=0: fetch() adds Cache-Control: no-cache to conditional requests on its own, and the API never answers 304 to that, so without the explicit header every read downloads the full body again. updateIfMatch sends If-Match and throws ContentPreconditionFailedError with currentETag when someone changed the resource since your read.
import { getIfChanged, updateIfMatch } from '@tutorflow/content-api-sdk'
const read = await getIfChanged(savedETag, ({ headers }) =>
client.GET('/v1/content/classrooms/{classroomId}/courses/{id}', { params: { path: { classroomId, id } }, headers }),
)
if (!read.notModified) {
await updateIfMatch(read.etag!, ({ headers }) =>
client.PATCH('/v1/content/classrooms/{classroomId}/courses/{id}', {
params: { path: { classroomId, id } },
body: { title: 'Fractions for Grade 5 (2026)' },
headers,
}),
)
}Verify webhooks
Pass the raw body, before any JSON parsing, the X-Content-Integration-Signature-V2 header, and your secret. During a rotation, pass both secrets. Verification accepts a timestamp up to 300 seconds from your clock and returns the event with livemode.
import { isWebhookEventType, verifyWebhook } from '@tutorflow/content-api-sdk'
const event = await verifyWebhook({
payload: rawBody,
header: req.get('X-Content-Integration-Signature-V2'),
secret: [process.env.TUTORFLOW_WEBHOOK_SECRET!, process.env.TUTORFLOW_WEBHOOK_SECRET_PREVIOUS ?? ''],
})
if (isWebhookEventType(event, 'course.generation.completed') && event.livemode) {
await queueCourseImport(event.event.id, event.resourceId)
}from tutorflow_content import verify_webhook
event = verify_webhook(
request.get_data(),
request.headers.get("X-Content-Integration-Signature-V2"),
[secret for secret in (current_secret, previous_secret) if secret],
)
if event["event"]["type"] == "course.generation.completed" and event["livemode"]:
queue_course_import(event["event"]["id"], event["resourceId"])A failed check raises WebhookVerificationError with a reason: missing_header, malformed_header, timestamp_out_of_tolerance, no_matching_signature, or invalid_payload. Answer it with 400. The algorithm is the one described in Verify the signature.
Retry rules
| Answer | Retried | Wait |
|---|---|---|
429 | Always: the request was refused before any work | Retry-After, up to 60 seconds. A longer wait is returned to you as the 429. |
500, 502, 503, 504, connection failure | Only for GET, PUT, DELETE, and requests with an Idempotency-Key | Backoff from 0.5 seconds, doubling with jitter, at most 8 seconds |
| Anything else | No |
Two retries by default. Change it with createClient({ retry: { maxRetries: 4 } }) or create_client(key, retry=RetryConfig(max_retries=4)), or turn retries off with retry: false or retry=None.
Keeping the SDKs current
Both SDKs are generated from apps/frontend-admin/public/resources/integrations/openapi.json. When the API changes, the file is regenerated from the backend and the SDK code with it; pnpm --filter @tutorflow/content-api-sdk check:generated and python scripts/generate.py --check (in sdks/python) fail when the generated code is stale. Check the changelog for what changed.