After this guide an agent knows when generated content is ready, without guessing. It polls the run, or receives a signed webhook, and recovers a failed course run without paying for the lessons already written.
Every generate route (POST .../courses/generate, .../tests/generate, .../modules/generate, .../slides/generate) answers 202 with a runId and a statusUrl. The run is the one thing to follow. If you have not started one yet, see Generate Your First Content.
Poll the run
curl "https://api.tutorflow.io/v1/content/classrooms/$CLASSROOM_ID/runs/$RUN_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"A finished course run:
{
"runId": "6bff...",
"kind": "course",
"classroomId": "0fce...",
"status": "completed",
"stage": "generating_lessons",
"progress": { "completed": 1, "total": 1, "unit": "lesson" },
"resourceId": "5d2a...",
"creditsCharged": 6,
"estimatedCredits": 6,
"error": null,
"createdAt": "2026-10-16T18:42:38.524Z",
"finishedAt": "2026-10-16T18:43:52.506Z",
"resumedFromRunId": null
}| Field | What to do with it |
|---|---|
status | queued, then running, then completed or failed. Stop polling on completed or failed. |
stage | Where the run is. A course goes outlining, creating_course, generating_lessons. null while queued. New stages may be added, so show unknown ones as they are. |
progress | completed of total, in unit (lesson, page, step, or test). For a course, total is 0 until the outline exists. |
resourceId | The course, test, module, or deck id. It appears as soon as the resource exists, before the run finishes. |
creditsCharged | Credits charged so far. estimatedCredits was the quote when the run started. |
error | Why the run failed, or null. |
These are the states one course run went through, read every 25 seconds:
running outlining 0/0 lesson 0 credits
running generating_lessons 0/1 lesson 3 credits
completed generating_lessons 1/1 lesson 6 creditsPoll every 20 to 30 seconds. Every poll counts toward the key's rate limit of 60 requests per minute; over it, the poll answers 429 content_rate_limit_exceeded with Retry-After (see Rate Limiting). A runId that is not in the classroom named in the path answers 404 content_not_found. GET .../runs lists the classroom's runs, newest first, with kind, status, limit, and cursor filters.
A polling script
This Node.js 18+ script waits for any run, then prints the title and editor link of what it made. It backs off when it gets 429.
// wait-for-run.mjs: node wait-for-run.mjs <classroomId> <runId> (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 POLL_INTERVAL_MS = 20_000
const RESOURCE_PATHS = { course: 'courses', test: 'tests', module: 'modules', slide: 'slides' }
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function getJson(path) {
for (;;) {
const response = await fetch(`${BASE_URL}${path}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})
if (response.status === 429) {
await sleep(Number(response.headers.get('retry-after') ?? 10) * 1000)
continue
}
const body = await response.json()
if (!response.ok) {
throw new Error(`${response.status} ${body.error?.code}: ${body.error?.message}`)
}
return body
}
}
async function waitForRun(classroomId, runId) {
for (;;) {
const run = await getJson(`/v1/content/classrooms/${classroomId}/runs/${runId}`)
const { completed, total, unit } = run.progress
console.log(`${run.status} ${run.stage ?? ''} ${completed}/${total} ${unit}, ${run.creditsCharged} credits`)
if (run.status === 'completed' || run.status === 'failed') return run
await sleep(POLL_INTERVAL_MS)
}
}
const [classroomId, runId] = process.argv.slice(2)
const run = await waitForRun(classroomId, runId)
if (run.status === 'failed') {
console.error(`Run failed: ${run.error}`)
process.exit(1)
}
const resource = await getJson(
`/v1/content/classrooms/${classroomId}/${RESOURCE_PATHS[run.kind]}/${run.resourceId}`,
)
console.log(resource.title)
console.log(resource.editorUrl)node wait-for-run.mjs "$CLASSROOM_ID" "$RUN_ID"completed generating_lessons 1/1 lesson, 6 credits
Beginner Python for Adult Career Changers: Variables, Loops, and Functions
https://tutorflow.io/org/my-edtech-agent/my-edtech-agent/courses/chat/09cf.../lessonsWith the CLI, tutorflow run wait <runId> does the same. See Use the CLI.
When a run fails
A step that fails for a passing reason, such as a model timeout, is retried on its own, up to 3 attempts. The run fails when those attempts are used up, or at once on an error that retrying cannot fix. error then says why, for example a 402 when the balance ran out, or Stopped after 3 attempts at generating_lessons: ....
What to do depends on the kind:
| Failed run | What to do |
|---|---|
Course, with a resourceId | Resume it (below). The new run writes only the lessons left. |
Course without a resourceId, test, module, or deck | Start a new generate request with a new Idempotency-Key. |
Any run that failed with 402 | Top up first. See Pricing and Billing. |
A failed course or deck keeps what it already wrote, and you keep paying only for what was made. Time limits and every failure case are in Retries and failures.
Resume a failed course run
curl -i -X POST "https://api.tutorflow.io/v1/content/classrooms/$CLASSROOM_ID/runs/$FAILED_RUN_ID/resume" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Idempotency-Key: run-resume:$FAILED_RUN_ID:v1"The answer is 202 with a new run, shaped like a generate answer, whose resumedFromRunId is the failed run. It keeps the outline and every finished lesson, writes only the lessons left, and charges 3 credits for each of them. Follow the new runId as before.
A run can be resumed once, and only after it failed. Resuming a run in any other state answers 409:
{
"error": {
"code": "content_run_not_resumable",
"message": "Only a failed run can be resumed; this one is completed.",
"status": 409,
"requestId": "e3ac..."
}
}A run that was already resumed answers 409 content_run_already_resumed, and error.resumedByRunId names the run to follow instead. See Resume a failed course run.
Get a webhook instead
If your agent can receive HTTPS, TutorFlow can tell it when the run ends. There is one event per run, and no progress events.
| Kind | Events |
|---|---|
| Course | course.generation.completed, course.generation.failed |
| Test | test.generation.completed, test.generation.failed |
| Module | module.generation.completed, module.generation.failed |
| Slide deck | slide.generation.completed, slide.generation.failed |
1. Create an endpoint
curl -X POST https://api.tutorflow.io/v1/content/webhooks \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/tutorflow/webhooks",
"events": ["course.generation.completed", "course.generation.failed"]
}'201 Created:
{
"id": "797c...",
"organizationId": "e694...",
"url": "https://example.com/tutorflow/webhooks",
"events": ["course.generation.completed", "course.generation.failed"],
"status": "ACTIVE",
"mode": "live",
"previousSecretExpiresAt": null,
"consecutiveFailures": 0,
"failingSince": null,
"lastSuccessAt": null,
"disabledReason": null,
"disabledAt": null,
"createdAt": "2026-10-16T18:43:32.331Z",
"updatedAt": "2026-10-16T18:43:32.331Z",
"secret": "9f3a..."
}secret is the signing secret, a 64-character hex string. It is shown only in this answer, so store it now. An endpoint made with a live key gets live events; one made with a test key gets only sandbox events.
2. Verify every delivery
Each delivery is a POST with the header X-Content-Integration-Signature-V2: t=<unix seconds>,v1=<hex>. Before you trust the body:
- Read the raw body, before parsing JSON.
- Reject the request if
tis more than 5 minutes from your clock. - Compute HMAC-SHA256, keyed with
secret, over<t>.followed by the raw body. - Compare your hex digest with each
v1value in constant time.
A ready Node.js function is in Webhooks: verify the signature.
3. Act on the event
A run event carries the same fields as the run, plus resourceType, livemode, and event (id, type, createdAt):
{
"resourceType": "course",
"resourceId": "5d2a...",
"classroomId": "0fce...",
"runId": "6bff...",
"status": "completed",
"stage": "generating_lessons",
"progress": { "completed": 1, "total": 1, "unit": "lesson" },
"creditsCharged": 6,
"error": null,
"livemode": true,
"event": {
"id": "a1f0...",
"type": "course.generation.completed",
"createdAt": "2026-10-16T18:43:52.600Z"
}
}Answer 2xx quickly, then read the resource by resourceId for its content and editorUrl. Deliveries can repeat, so ignore an event.id you have already handled. On course.generation.failed with a resourceId, resume the runId.
POST /v1/content/webhooks/{webhookId}/test sends a webhook.ping to check your receiver. Every route, retry rule, and the delivery log are in Webhooks and the full Content API Webhooks reference.
Next
- Pricing and Billing: what runs cost and what to do when credits run out.