@tutorflow/content-api-sdk
v0.1.0
Published
TypeScript client for the TutorFlow Content API: typed routes, retries, pagination, run polling, ETags and webhook verification.
Readme
@tutorflow/content-api-sdk
A TypeScript client for the TutorFlow Content API (/v1/content). Every route and response is typed from the published OpenAPI document, and the helpers cover what an integration otherwise writes by hand: retries, idempotency keys, pagination, waiting for runs, ETags, and webhook signatures.
Install
Requires Node.js 20 or later.
pnpm add @tutorflow/content-api-sdkThe package is ESM only and ships its type declarations in dist/. Documentation is at tutorflow.io/resources/integrations/content-api-sdks.
Create a client
Keep the key on your server. Never send it to a browser.
import { createClient, unwrap } from '@tutorflow/content-api-sdk'
const client = createClient({ apiKey: process.env.TUTORFLOW_CONTENT_API_KEY! })
const classrooms = unwrap(await client.GET('/v1/content/classrooms'))Calls resolve to { data, error, response } as in openapi-fetch. unwrap() returns data or throws a ContentApiError with code, status, requestId, retryAfterSeconds, currentETag and details from the error envelope. A 412 throws its subclass ContentPreconditionFailedError.
A test key (tf_content_test_...) works the same way against the sandbox classroom; isTestKey(apiKey) tells them apart.
Retries and idempotency
The client retries on its own:
429: always, afterRetry-After(up to 60 seconds; a longer wait is returned to you as the 429).500,502,503,504and network failures: only forGET,PUT,DELETEand requests with anIdempotency-Key, so a change is never applied twice.
Backoff starts at 500 ms and doubles with jitter, two retries by default. Tune it with createClient({ retry: { maxRetries, baseDelayMs, maxDelayMs, maxRetryAfterMs } }) or turn it off with retry: false.
withIdempotencyKey() adds an Idempotency-Key (a new UUID unless you pass one). Every retry sends the same key, and the API replays the first answer instead of acting again:
import { withIdempotencyKey } from '@tutorflow/content-api-sdk'
const run = unwrap(
await client.POST(
'/v1/content/classrooms/{classroomId}/courses/generate',
withIdempotencyKey({
params: { path: { classroomId } },
body: { topic: 'Fractions for Grade 5', lessonCount: 6, language: 'en' },
}),
),
)Pagination
paginate() yields every item of a list. It reads both envelopes: page-numbered lists (meta.page, meta.hasNextPage) and cursor lists (meta.nextCursor). Spread the next argument into the query and keep the other filters the same:
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)
}Waiting for runs
waitForRun() polls until a run settles and returns its final status. It does not throw when the run failed; check the status it returns.
import { waitForRun } from '@tutorflow/content-api-sdk'
// Test, module, course and slide generation: GET .../runs/{runId}
const finished = await waitForRun(client, { type: 'generation', classroomId, runId: run.runId })
if (finished.status === 'failed') throw new Error(finished.error ?? 'Generation failed')
// Game or simulation brief, build, revise: GET .../{games|simulations}/{id}/run
const build = await waitForRun(client, { type: 'build', kind: 'game', classroomId, resourceId: gameId, runId })
// Video render: GET .../videos/{id}/render
const render = await waitForRun(client, { type: 'render', classroomId, videoId })Options: intervalMs (default 3 s), timeoutMs (default 30 minutes), signal, onPoll. The build status route only knows runs of the server that answers since its last deploy; when a run is no longer tracked the wait throws ContentWaitError with reason: 'run_not_tracked', and the run's webhook is the signal to rely on. Webhooks are the better choice wherever you can receive them.
ETags
import { getIfChanged, updateIfMatch, ContentPreconditionFailedError } 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) save(read.data, read.etag)
try {
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,
}),
)
} catch (error) {
if (error instanceof ContentPreconditionFailedError) {
// Someone changed the course since the read: read it again and re-apply.
}
}getIfChanged() sends If-None-Match together with Cache-Control: max-age=0. fetch() adds Cache-Control: no-cache to conditional requests by itself, and a server never answers 304 to that, so without the explicit header you would always get the full body back.
Webhooks
verifyWebhook() checks the X-Content-Integration-Signature-V2 header against the raw body and returns the parsed event, typed by event type, with livemode:
import express from 'express'
import { isWebhookEventType, verifyWebhook, WebhookVerificationError } from '@tutorflow/content-api-sdk'
const app = express()
app.post('/tutorflow/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await verifyWebhook({
payload: req.body, // the raw Buffer, before JSON parsing
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)
}
res.sendStatus(204)
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason)
throw error
}
})It accepts the request when t is within toleranceSeconds (default 300) of now and any v1= signature matches any secret you pass. During TutorFlow's secret rotation grace period the header carries a signature for the new and the previous secret, so receivers keep passing whichever secret they hold. Deliveries are signed again on every retry.
Types
paths, components, operations and webhooks are exported, and Schema<'ContentCourse'> names one schema.
Development
pnpm --filter @tutorflow/content-api-sdk test # node:test with a fake fetch
pnpm --filter @tutorflow/content-api-sdk typecheck
pnpm --filter @tutorflow/content-api-sdk build # tsc to dist/, ESM and .d.ts
pnpm --filter @tutorflow/content-api-sdk generate # rewrite src/generated/openapi.ts
pnpm --filter @tutorflow/content-api-sdk check:generated # fail when the types are stalesrc/generated/openapi.ts is generated with openapi-typescript from apps/frontend-admin/public/resources/integrations/openapi.json. That file comes from the backend: run pnpm run openapi:content -- <path to openapi.json> in tutorflow-backend, then generate here, and commit both.
