After this guide your server receives a signed request the moment a generation run, a video render, or an evaluation ends, and can prove each one came from TutorFlow.
Overview
Content work runs in the background: a generation run, a video render, an async evaluation. Instead of polling, register an HTTPS endpoint, and TutorFlow sends it a signed POST when the work finishes.
Agent accounts use Content API webhooks. The full reference, with every event, payload, header, retry, and the signature check in several languages, is Content API Webhooks. This page covers what an agent needs most.
Create an endpoint
Manage endpoints with your Content key (tf_content_; the key register returns holds the webhooks:manage scope). A key without that scope gets 403 content_insufficient_scope, and a legacy tf_platform_ key gets 401 content_platform_key_not_accepted. The old /v1/platform/webhooks routes answer 410 platform_route_removed.
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"]
}'The answer includes the signing secret once. Store it with your other secrets. An endpoint created with a live key is a live endpoint; one created with a test key is a test endpoint and gets only sandbox events. In the answer, organizationId is the TutorFlow organization id, not the workspace id that the account routes return. See Create an endpoint.
| Method | Route | Description |
|---|---|---|
POST | /v1/content/webhooks | Create an endpoint |
GET | /v1/content/webhooks | List endpoints |
GET | /v1/content/webhooks/{webhookId} | Read one endpoint |
PATCH | /v1/content/webhooks/{webhookId} | Change the URL, events, or status |
POST | /v1/content/webhooks/{webhookId}/rotate-secret | Rotate the signing secret |
POST | /v1/content/webhooks/{webhookId}/test | Send a test event |
GET | /v1/content/webhooks/{webhookId}/deliveries | Delivery log |
POST | /v1/content/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver | Send a delivery again |
DELETE | /v1/content/webhooks/{webhookId} | Delete an endpoint |
Events agents use most
| Event | Sent when |
|---|---|
course.generation.completed, course.generation.failed | A course generation run finished or failed |
test.generation.completed, test.generation.failed | The same for a test |
module.generation.completed, module.generation.failed | The same for a module |
slide.generation.completed, slide.generation.failed | The same for a slide deck |
video.render.completed, video.render.failed | A video render finished (the mp4 is ready) or failed |
game.build.completed, game.build.failed | A game build or revision finished or failed |
evaluation.completed, evaluation.failed | An evaluation made through the Content API finished or failed |
The Content API sends more, such as resource.*, learner.*, and api_key.expiring. See Events.
Payloads
Every body carries the event's fields at the top level, plus event (id, type, createdAt) and livemode.
A generation run:
{
"resourceType": "course",
"resourceId": "00000000-0000-4000-8000-000000000201",
"classroomId": "00000000-0000-4000-8000-000000000010",
"runId": "00000000-0000-4000-8000-000000000b01",
"status": "completed",
"stage": "generating_lessons",
"progress": { "completed": 6, "total": 6, "unit": "lesson" },
"creditsCharged": 21,
"error": null,
"livemode": true,
"event": {
"id": "00000000-0000-4000-8000-000000000906",
"type": "course.generation.completed",
"createdAt": "2026-10-16T09:24:10.000Z"
}
}An evaluation:
{
"resourceType": "evaluation",
"resourceId": "00000000-0000-4000-8000-000000000c01",
"classroomId": "00000000-0000-4000-8000-000000000010",
"evaluationType": "open_ended",
"status": "COMPLETED",
"learnerExternalId": "learner-42",
"score": 8,
"maxScore": 10,
"normalizedScore": 80,
"livemode": true,
"event": {
"id": "00000000-0000-4000-8000-000000000907",
"type": "evaluation.completed",
"createdAt": "2026-10-16T09:25:00.000Z"
}
}evaluation.failed carries error instead of the scores. After a run event, read the resource by resourceId for its content and editorUrl.
Verify the signature
Every delivery carries X-Content-Integration-Event, X-Content-Integration-Event-Id, and X-Content-Integration-Signature-V2:
X-Content-Integration-Signature-V2: t=1790758800,v1=9a41...v1 is a hex HMAC-SHA256, keyed with your endpoint secret, of <t>. followed by the raw request body. To verify:
- Read the raw body before parsing JSON.
- Reject the request if
tis more than 5 minutes from your clock. - Compute the HMAC of the bytes
<t>.followed by the raw body, with your secret. - Compare it with each
v1value in constant time.
// Node.js 18+
import crypto from 'node:crypto'
export function verifyTutorFlowSignature(rawBody, header, secret, nowMs = Date.now()) {
const parts = header.split(',').map((part) => part.trim())
const t = parts.find((part) => part.startsWith('t='))?.slice(2)
const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3))
if (!t || Math.abs(nowMs / 1000 - Number(t)) > 300) return false
const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${t}.`), rawBody]))
.digest('hex')
return signatures.some(
(signature) =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)),
)
}The full reference, with Fastify and Flask versions and secret rotation, is in Verify the signature.
Webhooks or polling
| Strategy | Best for |
|---|---|
| Webhooks | Server-side integrations with a public HTTPS endpoint. |
Polling GET /v1/content/classrooms/{classroomId}/runs/{runId} | Agents with no inbound HTTP. Poll every 10 to 30 seconds. |
Endpoints moved from the Agent Platform
The Agent Platform webhook routes (/v1/platform/webhooks) were removed on 2026-10-16. If you had Agent Platform webhook endpoints, they were copied to Content API endpoints for your account, with the same URL and the same signing secret, and with the events mapped (course.completed to course.generation.completed and course.generation.failed, and so on). Your receiver must read the new header and event names. See Webhooks in the migration guide.
LTI events
LTI events (lti.launch_completed, lti.grade_synced, lti.grade_sync_failed) were sent only to Agent Platform webhook endpoints, and those were removed on 2026-10-16. LTI event delivery is not available through the API for now. LTI registrations and launches keep working; see LTI Integration.
Best practices
- Verify the signature before doing anything else.
- Answer
2xxquickly and do the work afterwards. - Deduplicate on
event.id: retries and redeliveries keep it. - Use HTTPS endpoints only.