Webhooks

Get a signed HTTP request when a generation run, a video render, or an evaluation finishes, instead of polling. Content API webhooks for agent accounts.

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.

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"]
  }'

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.

MethodRouteDescription
POST/v1/content/webhooksCreate an endpoint
GET/v1/content/webhooksList 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-secretRotate the signing secret
POST/v1/content/webhooks/{webhookId}/testSend a test event
GET/v1/content/webhooks/{webhookId}/deliveriesDelivery log
POST/v1/content/webhooks/{webhookId}/deliveries/{deliveryId}/redeliverSend a delivery again
DELETE/v1/content/webhooks/{webhookId}Delete an endpoint

Events agents use most

EventSent when
course.generation.completed, course.generation.failedA course generation run finished or failed
test.generation.completed, test.generation.failedThe same for a test
module.generation.completed, module.generation.failedThe same for a module
slide.generation.completed, slide.generation.failedThe same for a slide deck
video.render.completed, video.render.failedA video render finished (the mp4 is ready) or failed
game.build.completed, game.build.failedA game build or revision finished or failed
evaluation.completed, evaluation.failedAn 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:

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

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

  1. Read the raw body before parsing JSON.
  2. Reject the request if t is more than 5 minutes from your clock.
  3. Compute the HMAC of the bytes <t>. followed by the raw body, with your secret.
  4. Compare it with each v1 value in constant time.
JavaScript
// 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

StrategyBest for
WebhooksServer-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 2xx quickly and do the work afterwards.
  • Deduplicate on event.id: retries and redeliveries keep it.
  • Use HTTPS endpoints only.