Every task below is shown in curl, Node.js 18+ (built-in fetch), and Python 3.9+ (requests). The Node.js and Python examples use the request() wrapper from the first section.
All examples read the same variables:
export TUTORFLOW_API_BASE_URL="https://api.tutorflow.io"
export TUTORFLOW_CONTENT_API_KEY="tf_content_..." # from Settings > Content API
export CLASSROOM_ID="00000000-0000-4000-8000-000000000010" # from GET /v1/content/classroomsA request wrapper that follows the retry rules
The wrapper implements the table in Errors:
429: waitRetry-Afterseconds, then send the same request again.5xx, network errors, and timeouts: retry with exponential backoff, but only forGET,PATCH, and requests that carry anIdempotency-Key, so a create that did succeed is never repeated.402: stop and raise. Retrying does not help until billing changes.409: raise with the code, so the caller reads the resource and decides.400,401,403,404,413,422: raise. Fix the request.
Every error it raises carries the response's Request-Id, so your logs hold the id TutorFlow support needs. requestWithMeta() also returns whether the answer was an idempotent replay (Idempotent-Replayed: true) and the Request-Id of successful calls; request() returns only the body.
// tutorflow.js, 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 MAX_ATTEMPTS = 5
const REQUEST_TIMEOUT_MS = 30_000
const RETRYABLE_STATUSES = new Set([500, 502, 503, 504])
export class TutorFlowError extends Error {
constructor(status, body, requestId) {
super(`${body?.error?.message ?? `HTTP ${status}`} (Request-Id: ${requestId ?? 'none'})`)
this.status = status
this.code = body?.error?.code ?? null
this.requestId = body?.error?.requestId ?? requestId ?? null
this.body = body
}
}
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const backoffMs = (attempt) => Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 250
export async function requestWithMeta(method, path, { body, idempotencyKey, headers = {} } = {}) {
const canRetry = method === 'GET' || method === 'PATCH' || Boolean(idempotencyKey)
for (let attempt = 1; ; attempt++) {
let response
try {
response = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
} catch (networkError) {
if (!canRetry || attempt >= MAX_ATTEMPTS) throw networkError
await sleep(backoffMs(attempt))
continue
}
const requestId = response.headers.get('request-id')
if (response.ok) {
const text = await response.text()
return {
body: text ? JSON.parse(text) : null,
isReplayed: response.headers.get('idempotent-replayed') === 'true',
requestId,
}
}
const errorBody = await response.json().catch(() => null)
if (response.status === 429 && attempt < MAX_ATTEMPTS) {
await sleep(Number(response.headers.get('retry-after') ?? '1') * 1000)
continue
}
if (RETRYABLE_STATUSES.has(response.status) && canRetry && attempt < MAX_ATTEMPTS) {
await sleep(backoffMs(attempt))
continue
}
throw new TutorFlowError(response.status, errorBody, requestId)
}
}
export async function request(method, path, options) {
const { body } = await requestWithMeta(method, path, options)
return body
}# tutorflow.py, Python 3.9+ with requests
import os
import random
import time
import requests
BASE_URL = os.environ.get("TUTORFLOW_API_BASE_URL", "https://api.tutorflow.io")
API_KEY = os.environ["TUTORFLOW_CONTENT_API_KEY"]
MAX_ATTEMPTS = 5
REQUEST_TIMEOUT_SECONDS = 30
RETRYABLE_STATUSES = {500, 502, 503, 504}
class TutorFlowError(Exception):
def __init__(self, status, body, request_id=None):
error = (body or {}).get("error", {}) if isinstance(body, dict) else {}
self.request_id = error.get("requestId") or request_id
super().__init__(f"{error.get('message', f'HTTP {status}')} (Request-Id: {self.request_id})")
self.status = status
self.code = error.get("code")
self.body = body
def _backoff_seconds(attempt):
return min(30, 2 ** (attempt - 1)) + random.random() / 4
def request_with_meta(method, path, body=None, idempotency_key=None, headers=None):
can_retry = method in ("GET", "PATCH") or bool(idempotency_key)
all_headers = {"Authorization": f"Bearer {API_KEY}", **(headers or {})}
if idempotency_key:
all_headers["Idempotency-Key"] = idempotency_key
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
response = requests.request(
method,
f"{BASE_URL}{path}",
json=body,
headers=all_headers,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException:
if not can_retry or attempt == MAX_ATTEMPTS:
raise
time.sleep(_backoff_seconds(attempt))
continue
request_id = response.headers.get("Request-Id")
if response.ok:
return {
"body": response.json() if response.content else None,
"is_replayed": response.headers.get("Idempotent-Replayed") == "true",
"request_id": request_id,
}
try:
error_body = response.json()
except ValueError:
error_body = None
if response.status_code == 429 and attempt < MAX_ATTEMPTS:
time.sleep(int(response.headers.get("Retry-After", "1")))
continue
if response.status_code in RETRYABLE_STATUSES and can_retry and attempt < MAX_ATTEMPTS:
time.sleep(_backoff_seconds(attempt))
continue
raise TutorFlowError(response.status_code, error_body, request_id)
def request(method, path, body=None, idempotency_key=None, headers=None):
return request_with_meta(method, path, body, idempotency_key, headers)["body"]Check the key
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/credits" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"import { request } from './tutorflow.js'
const balance = await request('GET', '/v1/content/credits')
console.log(`available credits: ${balance.availableCredit}`)from tutorflow import request
balance = request("GET", "/v1/content/credits")
print("available credits:", balance["availableCredit"])List classrooms
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" | jq '.[] | {id, name}'import { request } from './tutorflow.js'
const classrooms = await request('GET', '/v1/content/classrooms')
for (const classroom of classrooms) console.log(classroom.id, classroom.name)from tutorflow import request
classrooms = request("GET", "/v1/content/classrooms")
for classroom in classrooms:
print(classroom["id"], classroom["name"])Create, update, and delete a module
MODULE_ID="$(curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: module:intro-to-safety:v1" \
-d '{ "title": "Introduction to safety", "content": "<p>Report every hazard you see.</p>", "metadata": { "externalId": "intro-to-safety" } }' \
| jq -r '.id')"
curl -sS -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules/$MODULE_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Workplace safety basics" }'
curl -sS -X DELETE "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules/$MODULE_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"import { request } from './tutorflow.js'
const classroomPath = `/v1/content/classrooms/${process.env.CLASSROOM_ID}`
const created = await request('POST', `${classroomPath}/modules`, {
idempotencyKey: 'module:intro-to-safety:v1',
body: {
title: 'Introduction to safety',
content: '<p>Report every hazard you see.</p>',
metadata: { externalId: 'intro-to-safety' },
},
})
const updated = await request('PATCH', `${classroomPath}/modules/${created.id}`, { body: { title: 'Workplace safety basics' } })
console.log(updated.title, updated.updatedAt)
const deleted = await request('DELETE', `${classroomPath}/modules/${created.id}`)
console.log(deleted.deleted)import os
from tutorflow import request
classroom_path = f"/v1/content/classrooms/{os.environ['CLASSROOM_ID']}"
module = request(
"POST",
f"{classroom_path}/modules",
idempotency_key="module:intro-to-safety:v1",
body={
"title": "Introduction to safety",
"content": "<p>Report every hazard you see.</p>",
"metadata": {"externalId": "intro-to-safety"},
},
)
updated = request("PATCH", f"{classroom_path}/modules/{module['id']}", body={"title": "Workplace safety basics"})
print(updated["title"], updated["updatedAt"])
deleted = request("DELETE", f"{classroom_path}/modules/{module['id']}")
print(deleted["deleted"])PATCH /modules/{moduleId} answers with the updated module, as the single read returns it. It also still carries success: true, which is deprecated; do not read it. See Update responses.
Sync changes incrementally
List only what changed since the last run with updatedSince, oldest change first, and page until meta.hasNextPage is false. Store the time the run started, and use it as the next run's updatedSince. Deletions are not listed; take them from the resource.deleted webhook. See Syncing changes.
SINCE="2026-09-29T00:00:00Z" # the start time of your previous run
curl -sS -G "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
--data-urlencode "updatedSince=$SINCE" \
-d field=updatedAt -d order=ASC -d limit=100 -d page=1 \
| jq '{ changed: [.data[] | {id, title, updatedAt}], hasNextPage: .meta.hasNextPage }'import { request } from './tutorflow.js'
const PAGE_SIZE = 100
export async function syncChanged(resource, since, onItem) {
const runStartedAt = new Date().toISOString()
const basePath = `/v1/content/classrooms/${process.env.CLASSROOM_ID}/${resource}`
for (let page = 1; ; page++) {
const query = new URLSearchParams({ updatedSince: since, field: 'updatedAt', order: 'ASC', limit: String(PAGE_SIZE), page: String(page) })
const { data, meta } = await request('GET', `${basePath}?${query}`)
for (const item of data) await onItem(item)
if (!meta.hasNextPage) break
}
return runStartedAt // store it; pass it as `since` next time
}
const nextSince = await syncChanged('modules', '2026-09-29T00:00:00Z', async (module) => {
console.log('changed', module.id, module.updatedAt)
})import os
from datetime import datetime, timezone
from urllib.parse import urlencode
from tutorflow import request
PAGE_SIZE = 100
def sync_changed(resource, since, on_item):
run_started_at = datetime.now(timezone.utc).isoformat()
base_path = f"/v1/content/classrooms/{os.environ['CLASSROOM_ID']}/{resource}"
page = 1
while True:
query = urlencode({"updatedSince": since, "field": "updatedAt", "order": "ASC", "limit": PAGE_SIZE, "page": page})
result = request("GET", f"{base_path}?{query}")
for item in result["data"]:
on_item(item)
if not result["meta"]["hasNextPage"]:
return run_started_at # store it; pass it as `since` next time
page += 1
next_since = sync_changed("modules", "2026-09-29T00:00:00Z", lambda module: print("changed", module["id"], module["updatedAt"]))The same loop works for courses, videos, slides, tests, games, and simulations.
Tell a replay from a new create
A retried create with the same Idempotency-Key returns the first response with Idempotent-Replayed: true. Use it to avoid recording the same resource twice, or to log that a retry was absorbed.
import { requestWithMeta } from './tutorflow.js'
const { body: course, isReplayed, requestId } = await requestWithMeta(
'POST',
`/v1/content/classrooms/${process.env.CLASSROOM_ID}/courses`,
{ idempotencyKey: 'course:customer-onboarding:v1', body: { title: 'Customer onboarding', visibility: 'PRIVATE' } },
)
console.log(isReplayed ? 'already created' : 'created', course.id, 'Request-Id', requestId)import os
from tutorflow import request_with_meta
result = request_with_meta(
"POST",
f"/v1/content/classrooms/{os.environ['CLASSROOM_ID']}/courses",
idempotency_key="course:customer-onboarding:v1",
body={"title": "Customer onboarding", "visibility": "PRIVATE"},
)
course = result["body"]
print("already created" if result["is_replayed"] else "created", course["id"], "Request-Id", result["request_id"])When a call fails, TutorFlowError carries requestId. Log it, and quote it to TutorFlow support; see Contacting support.
Expand source JSON and wait for the result
The polling here goes through request(), which already waits on 429. A curl loop is in the Quickstart.
import { request, sleep } from './tutorflow.js'
const POLL_INTERVAL_MS = 5000
const POLL_DEADLINE_MS = 15 * 60 * 1000
const job = await request('POST', '/v1/content/integrations/expansions', {
idempotencyKey: 'expansion:language-basics-a1:v1',
body: {
classroomId: process.env.CLASSROOM_ID,
requestedOutputs: ['interactive_module', 'summary_video', 'expanded_quiz'],
payload: {
language: 'en',
category: { id: 'language-basics', title: 'Language Basics' },
level: {
id: 'level-a1',
title: 'A1 Foundations',
lessons: [
{
id: 'lesson-greetings',
type: 'vocabulary',
title: 'Basic greetings',
items: [{ term: 'hello', meaning: 'a greeting', example: 'Hello, Mina.' }],
},
],
},
},
},
})
const deadline = Date.now() + POLL_DEADLINE_MS
let current = job
while (current.status === 'queued' || current.status === 'processing') {
if (Date.now() > deadline) throw new Error(`Job ${job.id} is still ${current.status}`)
await sleep(POLL_INTERVAL_MS)
current = await request('GET', `/v1/content/integrations/expansions/${job.id}`)
}
const result = await request('GET', `/v1/content/integrations/expansions/${job.id}/result`)
for (const output of result.outputs) {
console.log(output.outputType, output.status, output.resourceType, output.resourceId)
}import os
import time
from tutorflow import request
POLL_INTERVAL_SECONDS = 5
POLL_DEADLINE_SECONDS = 15 * 60
job = request(
"POST",
"/v1/content/integrations/expansions",
idempotency_key="expansion:language-basics-a1:v1",
body={
"classroomId": os.environ["CLASSROOM_ID"],
"requestedOutputs": ["interactive_module", "summary_video", "expanded_quiz"],
"payload": {
"language": "en",
"category": {"id": "language-basics", "title": "Language Basics"},
"level": {
"id": "level-a1",
"title": "A1 Foundations",
"lessons": [
{
"id": "lesson-greetings",
"type": "vocabulary",
"title": "Basic greetings",
"items": [{"term": "hello", "meaning": "a greeting", "example": "Hello, Mina."}],
}
],
},
},
},
)
deadline = time.monotonic() + POLL_DEADLINE_SECONDS
current = job
while current["status"] in ("queued", "processing"):
if time.monotonic() > deadline:
raise TimeoutError(f"Job {job['id']} is still {current['status']}")
time.sleep(POLL_INTERVAL_SECONDS)
current = request("GET", f"/v1/content/integrations/expansions/{job['id']}")
result = request("GET", f"/v1/content/integrations/expansions/{job['id']}/result")
for output in result["outputs"]:
print(output["outputType"], output["status"], output["resourceType"], output["resourceId"])Build a game asynchronously
Create the game, request a brief and a build in async mode, and follow each on GET .../run by its runId. In production, wait for the game.brief.completed and game.build.completed webhooks instead of polling. Prices are in Pricing.
GAMES="$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/games"
AUTH="Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
GAME_ID="$(curl -sS -X POST "$GAMES" -H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: game:fractions-run:v1" \
-d '{ "title": "Fractions run", "visibility": "PRIVATE", "metadata": { "topic": "Comparing fractions", "audience": "Grade 5", "language": "en" } }' \
| jq -r '.id')"
wait_for_run() {
until [ "$(curl -sS "$GAMES/$GAME_ID/run" -H "$AUTH" | jq -r --arg run "$1" '.last.runId == $run')" = "true" ]; do sleep "$2"; done
curl -sS "$GAMES/$GAME_ID/run" -H "$AUTH" | jq '.last'
}
BRIEF_RUN_ID="$(curl -sS -X POST "$GAMES/$GAME_ID/brief" -H "$AUTH" -H "Content-Type: application/json" \
-H "Prefer: respond-async" -H "Idempotency-Key: game:$GAME_ID:brief:v1" -d '{}' | jq -r '.runId')"
wait_for_run "$BRIEF_RUN_ID" 10
BUILD_RUN_ID="$(curl -sS -X POST "$GAMES/$GAME_ID/build" -H "$AUTH" \
-H "Prefer: respond-async" -H "Idempotency-Key: game:$GAME_ID:build:v1" | jq -r '.runId')"
wait_for_run "$BUILD_RUN_ID" 20import { request, sleep } from './tutorflow.js'
const gamesPath = `/v1/content/classrooms/${process.env.CLASSROOM_ID}/games`
const ASYNC = { Prefer: 'respond-async' }
const BRIEF_DEADLINE_MS = 10 * 60 * 1000
const BUILD_DEADLINE_MS = 35 * 60 * 1000
async function waitForRun(gameId, runId, intervalMs, deadlineMs) {
const deadline = Date.now() + deadlineMs
for (;;) {
const { last } = await request('GET', `${gamesPath}/${gameId}/run`)
if (last?.runId === runId) {
if (last.status !== 'done') throw new Error(`${last.phase} failed: ${last.message} ${(last.violations ?? []).join(' | ')}`)
return last
}
if (Date.now() > deadline) throw new Error(`Timed out waiting for run ${runId}`)
await sleep(intervalMs)
}
}
const game = await request('POST', gamesPath, {
idempotencyKey: 'game:fractions-run:v1',
body: { title: 'Fractions run', visibility: 'PRIVATE', metadata: { topic: 'Comparing fractions', audience: 'Grade 5', language: 'en' } },
})
const brief = await request('POST', `${gamesPath}/${game.id}/brief`, { body: {}, headers: ASYNC, idempotencyKey: `game:${game.id}:brief:v1` })
await waitForRun(game.id, brief.runId, 10_000, BRIEF_DEADLINE_MS)
const briefed = await request('GET', `${gamesPath}/${game.id}`)
console.log('plan:', briefed.brief.pitch)
const build = await request('POST', `${gamesPath}/${game.id}/build`, { headers: ASYNC, idempotencyKey: `game:${game.id}:build:v1` })
await waitForRun(game.id, build.runId, 20_000, BUILD_DEADLINE_MS)
const built = await request('GET', `${gamesPath}/${game.id}`)
console.log('version', built.contentVersion, 'html bytes', built.content.length)import os
import time
from tutorflow import request
games_path = f"/v1/content/classrooms/{os.environ['CLASSROOM_ID']}/games"
ASYNC = {"Prefer": "respond-async"}
def wait_for_run(game_id, run_id, interval_seconds, deadline_seconds):
deadline = time.monotonic() + deadline_seconds
while True:
last = request("GET", f"{games_path}/{game_id}/run").get("last") or {}
if last.get("runId") == run_id:
if last["status"] != "done":
raise RuntimeError(f"{last['phase']} failed: {last.get('message')} {last.get('violations')}")
return last
if time.monotonic() > deadline:
raise TimeoutError(f"Timed out waiting for run {run_id}")
time.sleep(interval_seconds)
game = request(
"POST",
games_path,
idempotency_key="game:fractions-run:v1",
body={"title": "Fractions run", "visibility": "PRIVATE", "metadata": {"topic": "Comparing fractions", "audience": "Grade 5", "language": "en"}},
)
brief = request("POST", f"{games_path}/{game['id']}/brief", body={}, headers=ASYNC, idempotency_key=f"game:{game['id']}:brief:v1")
wait_for_run(game["id"], brief["runId"], 10, 10 * 60)
briefed = request("GET", f"{games_path}/{game['id']}")
print("plan:", briefed["brief"]["pitch"])
build = request("POST", f"{games_path}/{game['id']}/build", headers=ASYNC, idempotency_key=f"game:{game['id']}:build:v1")
wait_for_run(game["id"], build["runId"], 20, 35 * 60)
built = request("GET", f"{games_path}/{game['id']}")
print("version", built["contentVersion"], "html bytes", len(built["content"]))Simulations use the same calls under /simulations. last on GET .../run shows the run that finished most recently and is kept for 1 hour, so poll within that hour or rely on the webhooks.
Render a video and wait for it
Render a video once its scenes have a script. Send generateMissingNarration: true to narrate scenes that have none in the same request (1 AI Credit per scene); without it a scene without narration returns 400 content_video_narration_missing. See Video Rendering.
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Idempotency-Key: video:$VIDEO_ID:render:v1" \
-H "Content-Type: application/json" \
-d '{ "generateMissingNarration": true }'
until [ "$(curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" | jq -r '.renderStatus')" != "RENDERING" ]; do sleep 15; done
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" | jq '{renderStatus, videoUrl, videoUrlExpiresAt}'import { request, sleep } from './tutorflow.js'
const videoPath = `/v1/content/classrooms/${process.env.CLASSROOM_ID}/videos/${process.env.VIDEO_ID}`
const started = await request('POST', `${videoPath}/render`, {
body: { generateMissingNarration: true },
idempotencyKey: `video:${process.env.VIDEO_ID}:render:v1`,
})
await sleep(Math.min(started.estimatedSeconds, 60) * 1000)
let status = await request('GET', `${videoPath}/render`)
while (status.renderStatus === 'RENDERING') {
await sleep(15_000)
status = await request('GET', `${videoPath}/render`)
}
if (status.renderStatus !== 'COMPLETED') throw new Error(status.renderError ?? 'Render did not complete')
console.log('mp4:', status.videoUrl, 'expires', status.videoUrlExpiresAt)import os
import time
from tutorflow import request
video_id = os.environ["VIDEO_ID"]
video_path = f"/v1/content/classrooms/{os.environ['CLASSROOM_ID']}/videos/{video_id}"
started = request(
"POST",
f"{video_path}/render",
body={"generateMissingNarration": True},
idempotency_key=f"video:{video_id}:render:v1",
)
time.sleep(min(started["estimatedSeconds"], 60))
status = request("GET", f"{video_path}/render")
while status["renderStatus"] == "RENDERING":
time.sleep(15)
status = request("GET", f"{video_path}/render")
if status["renderStatus"] != "COMPLETED":
raise RuntimeError(status.get("renderError") or "Render did not complete")
print("mp4:", status["videoUrl"], "expires", status["videoUrlExpiresAt"])A render that is already running answers 409 content_video_render_in_progress; the wrapper raises it with code set, so check error.code and poll the status instead of starting again.
Receive webhooks
Complete receivers that verify X-Content-Integration-Signature-V2 against the raw body, in Express, Fastify, and Flask, are in Verify the signature. Register the endpoint with:
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/webhooks" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/tutorflow/webhooks", "events": ["content.completed", "content.failed", "game.build.completed", "game.build.failed", "video.render.completed", "video.render.failed"] }'import { request } from './tutorflow.js'
const endpoint = await request('POST', '/v1/content/webhooks', {
body: {
url: 'https://example.com/tutorflow/webhooks',
events: ['content.completed', 'content.failed', 'game.build.completed', 'game.build.failed', 'video.render.completed', 'video.render.failed'],
},
})
// Store endpoint.secret with your receiver. It is not returned again.from tutorflow import request
endpoint = request(
"POST",
"/v1/content/webhooks",
body={
"url": "https://example.com/tutorflow/webhooks",
"events": ["content.completed", "content.failed", "game.build.completed", "game.build.failed", "video.render.completed", "video.render.failed"],
},
)
# Store endpoint["secret"] with your receiver. It is not returned again.This needs a key with webhooks:manage and no classroom allowlist.