Migrating from the Platform content routes

The Agent Platform content routes were removed on 2026-10-16. What was removed, what stayed, the Content API route for each removed route, keys, webhooks, old share links, and MCP.

After this guide an integration built on the removed /v1/platform content routes works again on the Content API. New accounts can skip this page.

The content routes of the Agent Platform were removed on 2026-10-16. A call to one now answers 410 with the code platform_route_removed and a link to this page. Courses, modules, tests, slides, videos, games, evaluations, and the learner model live on the Content API, under /v1/content/classrooms/{classroomId}/..., and take a tf_content_ key. The Agent Platform stays as the account layer.

This page lists every removed route with its Content API replacement and what changes in the answer. The full Content API reference is at Content API Reference.

What was removed

  • Every route under /v1/platform/videos, /v1/platform/slides, /v1/platform/courses, /v1/platform/tests, /v1/platform/modules, /v1/platform/games, /v1/platform/evaluations, and /v1/platform/learners, including the edit-token and share-token routes.
  • The public token editors those routes opened (editToken, previewUrl).
  • The webhook endpoint routes under /v1/platform/webhooks.

The content you made on those routes was copied into your account's classroom. Find it with the Content API list routes, such as GET /v1/content/classrooms/{classroomId}/courses.

What stayed

  • /v1/platform/agent/*: register, account, billing, x402, email verification.
  • /v1/platform/health.
  • /v1/platform/pricing-catalog and /v1/platform/.well-known/agent.json.
  • The Agent Platform dashboard.
  • LTI registrations (/v1/platform/lti/registrations) and the LTI launch routes. See LTI Integration.
  • The MCP server at https://mcp.tutorflow.io/api/mcp.

Keys

A tf_platform_ key still signs the account routes above. It reaches no content route: the old ones are gone, and the Content API refuses it. Content work needs a tf_content_ key. Your agent account already has one. Register no longer issues tf_platform_ keys, but one an older account holds keeps working on the account routes.

Get a tf_content_ key

  • From registration. POST /v1/platform/agent/register returns contentApiKey, a tf_content_ key. Its apiKey is null. Use contentApiKey for everything.
  • From the dashboard. Sign in at tutorflow.io, open the Agent Platform dashboard, and create a key. The dashboard creates only tf_content_ keys. Choose Live or Test as the mode.

The raw key is shown once. Store it in your secrets manager.

Live and test keys

Live keyTest key
Starts withtf_content_tf_content_test_
ClassroomYour default classroom (defaultClassroomId)Only the sandbox classroom (sandboxClassroomId)
CreditsSpent from the account balanceNever spent; priced routes answer with canned content
Account routes (/v1/platform/agent/*)YesNo, 403 platform_content_key_too_narrow

Read both classroom ids from GET /v1/platform/agent/account with a live key. With only a test key, GET /v1/content/classrooms lists the sandbox. See Test Mode.

Which key works where

Routetf_content_ keytf_platform_ key
/v1/content/**YesNo, 401 content_platform_key_not_accepted
/v1/platform/agent/*, /v1/platform/health, /v1/platform/lti/registrationsYes, a live key with every classroom and the content:read, content:write, content:generate, and webhooks:manage scopes, like the key register and the dashboard issueYes

A tf_platform_ key on the Content API gets this 401:

JSON
{
  "error": {
    "code": "content_platform_key_not_accepted",
    "message": "Agent Platform keys (tf_platform_) do not work on the Content API. Use a tf_content_ key: agent register returns one as contentApiKey, and the agent dashboard issues new ones.",
    "status": 401,
    "requestId": "..."
  }
}

A missing or unknown key on an account route gets 401 platform_invalid_api_key.

Rotate a key

Rotate or revoke keys in the Agent Platform dashboard. Rotation returns a new raw key once; update your secret store and restart the clients that hold the old one. Rotating a tf_platform_ key gives another tf_platform_ key, which still reaches only the account routes, so create a tf_content_ key instead and revoke the old one once nothing uses it.

The classroom in every URL

Every Content API route sits under a classroom: /v1/content/classrooms/{classroomId}/.... An agent account has one default classroom, created at registration. Read its id once and keep it:

bash
curl https://api.tutorflow.io/v1/platform/agent/account \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
JSON
{
  "defaultClassroomId": "00000000-0000-4000-8000-000000000010",
  "sandboxClassroomId": null,
  "contentApiBaseUrl": "https://api.tutorflow.io/v1/content",
  "contentApiUrls": {
    "listClassrooms": "GET /v1/content/classrooms",
    "generateCourse": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/courses/generate",
    "listCourses": "GET /v1/content/classrooms/00000000-0000-4000-8000-000000000010/courses",
    "generateSlides": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/slides/generate",
    "generateTest": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/tests/generate",
    "createVideo": "POST /v1/content/classrooms/00000000-0000-4000-8000-000000000010/videos"
  }
}

sandboxClassroomId stays null until the account has a test key.

What changes in the answers

Old Platform route behaviorContent API behavior
Create routes generate synchronously, or asynchronously with mode: "async".Generate routes always answer 202 with a run. Follow it with GET .../runs/{runId} or a *.generation.completed webhook. See Generation runs. Evaluations keep mode.
The prompt field is prompt.The field is topic. Course generation still reads prompt when topic is absent; tests, modules, and slides need topic.
Edit tokens (editToken, previewUrl, POST /:id/edit-token) open a token editor.Resources carry editorUrl, the TutorFlow classroom editor. A person opens it with their own TutorFlow sign-in, as an admin of your account's organization: for an agent account, at first only the person who verified its email. There are no edit tokens.
Share tokens (shareToken, publicUrl on a Platform share page).Resources carry publicUrl while their public page is served, and null otherwise. Which change makes each page served is in Check and share what you made. Test-key resources have neither link, because the sandbox classroom has no editor or public pages.
Lists use limit and offset and answer { "items", "total" }.Resource lists use page and limit and answer { "data", "meta" }. Runs and evaluations use a cursor: send meta.nextCursor back as cursor. See List responses.
Create answers carry priceSnapshot in USD.A run carries estimatedCredits and creditsCharged. Every charge is in GET /v1/content/credits/history.
tier is accepted and ignored.Not accepted on generate routes. Unknown fields are dropped.
Errors use platform_* codes.Errors use content_* codes and carry requestId. See Errors.
Idempotency-Key header where documented.Idempotency-Key header on every create, generate, and render. An evaluation's idempotencyKey body field is ignored; send the header.

Route map

{classroomId} is your defaultClassroomId. Paths on the right are under /v1/content/classrooms/{classroomId}. Every route in the left column was removed on 2026-10-16.

Courses: /v1/platform/courses

Removed routeContent API replacement
POST /coursesPOST /courses/generate. 202 with a run. Send topic, and optionally title, description, level, lessonCount (1 to 30), and language. subject and tier are gone. To create a course from content you already have, POST /courses.
GET /coursesGET /courses
GET /courses/:idGET /courses/{courseId}. Add isIncludeStats=true for lessons, chapters, and stats.
PATCH /courses/:idPATCH /courses/{courseId}
PATCH /courses/:id/canonicalNo single bulk route. Use the chapter and lesson routes: GET /courses/{courseId}/chapters, POST /courses/{courseId}/chapters, PATCH /courses/{courseId}/chapters/reorder, POST /courses/{courseId}/chapters/{chapterId}/lessons.
PATCH /courses/:id/chapters/:chapterIdPATCH /courses/{courseId}/chapters/{chapterId}
PATCH /courses/:id/lessons/:sequencePATCH /courses/{courseId}/lessons/{lessonId}. Lessons are addressed by id, not sequence; read the ids from GET /courses/{courseId}/chapters.
PATCH /courses/:id/lessons/by-id/:lessonId/fullPATCH /courses/{courseId}/lessons/{lessonId}
POST /courses/:id/edit-tokeneditorUrl on the course.
GET, PATCH /courses/edit/:editToken, and every /courses/edit/:editToken/... route (full, canonical, lessons, lesson generation, infer-type)No token routes. A person edits in the classroom editor at editorUrl. Your integration uses the course, chapter, and lesson routes above.
GET /courses/public/:shareToken, .../full, .../lessons/:sequencepublicUrl on the course, for people. For your integration, GET /courses/{courseId}/chapters and GET /courses/{courseId}/lessons/{lessonId}.

See Courses and Course chapters and lessons.

Modules: /v1/platform/modules

Removed routeContent API replacement
POST /modulesPOST /modules/generate. 202 with a run. Send topic, and optionally type, referenceText, and language. To create a module from content you already have, POST /modules.
GET /modulesGET /modules
GET /modules/:idGET /modules/{moduleId}
PATCH /modules/:idPATCH /modules/{moduleId}
DELETE /modules/:idDELETE /modules/{moduleId}
GET /modules/public/:shareTokenpublicUrl on the module, set while it has isPublic: true.

Tests: /v1/platform/tests

Removed routeContent API replacement
POST /testsPOST /tests/generate. 202 with a run. Send topic, and optionally instructions, level (EASY, MEDIUM, HARD), itemCount (1 to 30), itemTypes, totalScore, passingValueType, passingValue, and language. A test is charged flat, not per item.
GET /testsGET /tests
GET /tests/:idGET /tests/{testId}
PATCH /tests/:idPATCH /tests/{testId}
PATCH /tests/:id/fullPATCH /tests/{testId} with items.
PATCH /tests/:id/items/by-id/:itemIdPATCH /tests/{testId} with the item, by its id, in items.
POST /tests/:id/edit-tokeneditorUrl on the test.
Every /tests/edit/:editToken/... route, including items/generateNo token routes. A person edits at editorUrl; your integration uses PATCH /tests/{testId}. There is no route that adds AI-written items to an existing test; generate a new test instead.
GET /tests/public/:shareToken, .../full, .../items/:sequence, .../takepublicUrl on the test, set while the test is PUBLIC or UNLISTED.
POST /tests/public/:shareToken/submissions, PATCH /tests/submissions/:submissionToken, GET /tests/submissions/:submissionToken/resultNo API for taking a test. Learners sign in and take it on the classroom page.
GET /tests/:id/submissionsGET /tests/{testId}/results and GET /tests/{testId}/results/{resultId}. Attempts by people who are not learners of the classroom have isAnonymous: true and a taker. Needs learners:read; not available to test keys.

See Tests and Learners.

Slides: /v1/platform/slides

Removed routeContent API replacement
POST /slidesPOST /slides/generate. 202 with a run. Send topic, and optionally instructions, slideCount, textLength, themeId, and language. To create a deck from content you already have, POST /slides.
GET /slidesGET /slides
GET /slides/:slideRequestIdGET /slides/{slideId}
PATCH /slides/:slideRequestIdPATCH /slides/{slideId}
GET, PATCH /slides/edit/:editTokeneditorUrl on the deck. Your integration uses GET and PATCH /slides/{slideId}.
POST /slides/edit/:editToken/generate/imageNo Content API route.
POST /slides/edit/:editToken/pptxNo Content API route.
GET /slides/public/:shareTokenpublicUrl on the deck, set while it is PUBLIC.

Videos: /v1/platform/videos

The Content API has no route that writes a whole narrated video from a prompt. You create the video, add and fill its scenes, generate narration from each scene's script, then render. See Videos and Video Rendering.

Removed routeContent API replacement
POST /videosPOST /videos (takes title, description, visibility), then POST /videos/{videoId}/scenes and PATCH /videos/{videoId}/scenes/{sceneId} for each scene, then POST /videos/{videoId}/narration. You write the scene scripts.
GET /videosGET /videos
GET /videos/:videoRequestIdGET /videos/{videoId}. Render progress is at GET /videos/{videoId}/render.
PATCH /videos/:videoRequestIdPATCH /videos/{videoId}
POST /videos/:videoRequestId/scenesPOST /videos/{videoId}/scenes
PATCH /videos/:videoRequestId/scenes/:sceneIdPATCH /videos/{videoId}/scenes/{sceneId}
DELETE /videos/:videoRequestId/scenes/:sceneIdDELETE /videos/{videoId}/scenes/{sceneId}
PUT /videos/:videoRequestId/scenes/reorderPATCH /videos/{videoId}/scenes/reorder with { "sceneIds": [...] }
GET, PATCH /videos/edit/:editTokeneditorUrl on the video. Your integration uses GET and PATCH /videos/{videoId}.
POST /videos/edit/:editToken/scenes, PATCH and DELETE .../scenes/:sceneId, PUT .../scenes/reorderThe scene routes above.
POST /videos/edit/:editToken/scenes/:sceneId/ttsPOST /videos/{videoId}/scenes/{sceneId}/narration
POST /videos/edit/:editToken/renderPOST /videos/{videoId}/render
POST /videos/edit/:editToken/render/cancelPOST /videos/{videoId}/render/cancel
POST /videos/edit/:editToken/scenes/:sceneId/clip/upload, .../visual/image/uploadUpload with purpose scene-visual (Asset Uploads), then set the scene's visualKey.
POST /videos/edit/:editToken/scenes/:sceneId/overlays/sticker/uploadUpload with purpose overlay, then set the scene's visualOverlays.
POST /videos/edit/:editToken/thumbnailUpload with purpose thumbnail, then set the video's thumbnailKey.
.../clip/search, .../clip/select, .../image/search, .../image/select, .../overlays/character/generate, /visual/slides, .../visual/slide, .../visual/slide/create, /bgm/search, /bgm/select, DELETE /bgm, /sync-durationsNo Content API route. A person does this in the editor at editorUrl. For your own music, upload with purpose bgm and set bgmAudioKey.
GET /videos/public/:shareTokenpublicUrl on the video, set once it is rendered, has a title, and is PUBLIC or UNLISTED.
POST /videos/render-callback/:platformVideoIdInternal to TutorFlow. Not for clients.

Games: /v1/platform/games

Removed routeContent API replacement
POST /gamesPOST /games (takes title and visibility), then POST /games/{gameId}/brief and POST /games/{gameId}/build. See Games.
GET /gamesGET /games
GET /games/:idGET /games/{gameId}
PATCH /games/:idPATCH /games/{gameId}
DELETE /games/:idDELETE /games/{gameId}
GET /games/public/:shareTokenpublicUrl on the game, set while it is PUBLIC and built. See Share and delete.

Evaluations: /v1/platform/evaluations

The request body is the same: evaluationType, questionText, learnerAnswer, referenceAnswer, rubric, maxScore, learnerExternalId or submitter, conceptTags, language, mode, clientMetadata, and the rest. JSON and multipart/form-data with rubricFiles and answerFiles both work.

Removed routeContent API replacement
POST /evaluationsPOST /evaluations. 201. Charges 1 credit. The learner comes back as learner: { "externalId", "name" }. Send Idempotency-Key as a header.
GET /evaluations/:idGET /evaluations/{evaluationId}
GET /evaluations/:id/files/:field/:indexGET /evaluations/{evaluationId}/files/{field}/{index}
GET /evaluationsGET /evaluations, newest first, with a cursor. Filter with status, evaluationType, and learner (the external learner id). List rows leave out the feedback and files; read one evaluation for them.

Learners: /v1/platform/learners

Removed routeContent API replacement
GET /learners/:learnerIdGET /mastery/{learner}, the learner's mastery of each concept, by the external id their evaluations were sent with. Needs learners:read.
GET /learnersNo list route. Keep your own list of learner ids, or list evaluations with the learner filter.
(new)GET /concepts: the account's concepts from evaluation concept tags, with their prerequisites. A test key gets an empty list.

The learner model is kept per organization, so every classroom of the account answers the same.

Webhook endpoints: /v1/platform/webhooks

Removed routeContent API replacement
POST /v1/platform/webhooksPOST /v1/content/webhooks with your key (webhooks:manage).
GET /v1/platform/webhooksGET /v1/content/webhooks
DELETE /v1/platform/webhooks/:idDELETE /v1/content/webhooks/{webhookId}

See Webhooks for testing, rotation, delivery logs, and redelivery.

Webhooks

The /v1/platform/webhooks routes were removed on 2026-10-16. Your Platform webhook endpoints were copied to Content API endpoints for the same account, with the same URL and the same signing secret. Each copy starts active if the Platform endpoint was active. Events were mapped:

Platform eventContent API events
video.completedvideo.render.completed, video.render.failed
test.completedtest.generation.completed, test.generation.failed
course.completedcourse.generation.completed, course.generation.failed
slide.completedslide.generation.completed, slide.generation.failed
evaluation.completedevaluation.completed
evaluation.failedevaluation.failed
lti.*Not copied. LTI event delivery is not available through the API for now.
assessment.*, *.deployedDropped. They were never sent.

A Platform *.completed event also carried failures, so it maps to both a completed and a failed event. Note that video.render.completed means the mp4 is ready; the Platform's video.completed meant the scenes were written.

What your receiver has to change:

PlatformContent API
Event headerX-Platform-EventX-Content-Integration-Event
Signature headerX-Platform-Signature: hex HMAC-SHA256 of the raw bodyX-Content-Integration-Signature: the same HMAC of the raw body, with the same secret
Recommended signatureX-Content-Integration-Signature-V2: t=<timestamp>,v1=<hmac> over <t>. plus the raw body. It resists replays; move to it. See Verify the signature.
BodyEvent-specific fieldsEvent fields at the top level, plus event (id, type, createdAt) and livemode. Deduplicate on event.id or X-Content-Integration-Event-Id.

The quickest switch: read X-Content-Integration-Signature where you read X-Platform-Signature, since the value is computed the same way. Then branch on the new event names and payloads. A run event carries runId, resourceId, status, creditsCharged, and error; an evaluation event carries resourceId (the evaluation id), evaluationType, status, learnerExternalId, and the scores or error.

Share links handed out before the removal keep working as links. A link of the form https://tutorflow.io/platform/{videos,slides,courses,tests,modules}/{shareToken} now redirects to the classroom public page of the copied content. When no classroom page matches, for example because the copy has no public page, the link shows a page that says the content has moved.

For new links, use the publicUrl the Content API returns on each resource.

Billing

There is one balance: the classroom credits of the organization behind your account. Content API calls and anything a person does in the TutorFlow editor both spend it.

  • Your old wallet, kept in cents, was converted once into credits at 2 US cents per credit, rounded down. A wallet that still held only the untouched $1.00 trial grant, with no usage, was not converted. New accounts start with 50 trial credits.
  • GET /v1/platform/agent/account answers credits and creditBalance. creditBalance is credits at 2 cents each, in cents, the unit the field always used.
  • Top-ups (Stripe checkout and x402 USDC deposits) buy credits at 2 cents per credit.
  • With a verified card, the account may overdraw up to its overdraftLimit, and the overdraft is billed by Stripe at 2 cents per credit.

See Pricing and Billing for the price of each call.

MCP

The MCP server at https://mcp.tutorflow.io/api/mcp runs its content tools on the Content API only. create_course, get_course_generation, list_courses, run_evaluation, and list_evaluations need a tf_content_ key and work in your default classroom (the sandbox for a test key). Called with a tf_platform_ key, they answer with an error that names tf_content_ keys and points to the Agent Platform dashboard; they no longer fall back to the removed routes. The account tools keep working with either key. To fix it, change the Authorization header in your MCP client configuration to Bearer tf_content_.... See MCP Integration.

Checklist

  1. Get a tf_content_ key from registration or the dashboard, and store it.
  2. Read defaultClassroomId from GET /v1/platform/agent/account.
  3. Find the content you made before 2026-10-16 in that classroom with the Content API list routes.
  4. Replace each removed route with its replacement above. Send topic instead of prompt.
  5. Replace synchronous waits with run polling or *.generation.* webhooks.
  6. Replace edit and share token handling with editorUrl and publicUrl.
  7. Update your webhook receiver to the Content API headers and event names.
  8. Switch the MCP client configuration to the tf_content_ key.