Follow a Generation

Know when a course, test, module, or slide deck is ready. Poll the run, read its status and stage, recover a failed course run with resume, or get a signed webhook when the run ends.

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

bash
curl "https://api.tutorflow.io/v1/content/classrooms/$CLASSROOM_ID/runs/$RUN_ID" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"

A finished course run:

JSON
{
  "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
}
FieldWhat to do with it
statusqueued, then running, then completed or failed. Stop polling on completed or failed.
stageWhere 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.
progresscompleted of total, in unit (lesson, page, step, or test). For a course, total is 0 until the outline exists.
resourceIdThe course, test, module, or deck id. It appears as soon as the resource exists, before the run finishes.
creditsChargedCredits charged so far. estimatedCredits was the quote when the run started.
errorWhy 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 credits

Poll 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.

JavaScript
// 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)
bash
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.../lessons

With 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 runWhat to do
Course, with a resourceIdResume it (below). The new run writes only the lessons left.
Course without a resourceId, test, module, or deckStart a new generate request with a new Idempotency-Key.
Any run that failed with 402Top 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

bash
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:

JSON
{
  "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.

KindEvents
Coursecourse.generation.completed, course.generation.failed
Testtest.generation.completed, test.generation.failed
Modulemodule.generation.completed, module.generation.failed
Slide deckslide.generation.completed, slide.generation.failed

1. Create an endpoint

bash
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:

JSON
{
  "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:

  1. Read the raw body, before parsing JSON.
  2. Reject the request if t is more than 5 minutes from your clock.
  3. Compute HMAC-SHA256, keyed with secret, over <t>. followed by the raw body.
  4. Compare your hex digest with each v1 value 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):

JSON
{
  "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