Webhooks tell your system when an expansion job finishes, when a resource changes through the Content API, when a generation run ends, when a video render ends, when a learner enrolls, finishes a course, or submits a test, and when an API key is about to expire. Start with polling, then add webhooks once your integration reads jobs and runs correctly.
Events
| Event | Sent when |
|---|---|
content.completed, content.failed | An expansion job reached completed, or failed on its final attempt. |
resource.created, resource.updated, resource.deleted | A Content API request created, changed, or deleted a classroom resource. A change inside a resource, such as a scene, a chapter, a lesson, or a restored version, is resource.updated on the parent. |
game.brief.completed, game.brief.failed | A game brief started through the Content API finished or failed. |
game.build.completed, game.build.failed | A game build or revision started through the Content API finished or failed. phase says which. |
simulation.brief.completed, simulation.brief.failed | The same for a simulation brief. |
simulation.build.completed, simulation.build.failed | The same for a simulation build or revision. |
test.generation.completed, test.generation.failed | A test generation run finished or failed. |
module.generation.completed, module.generation.failed | The same for a module generation run. |
course.generation.completed, course.generation.failed | The same for a course generation run. |
slide.generation.completed, slide.generation.failed | The same for a slide deck generation run. |
video.render.completed, video.render.failed | A classroom video render finished or failed, whether it started in TutorFlow or through the Content API. |
learner.enrolled | A learner was enrolled in a course, by anyone. Subscribing with a key needs learners:read. |
learner.course.completed | A learner finished every lesson of a course for the first time in an enrollment. Needs learners:read with a key. |
learner.test.submitted | A learner submitted a test attempt. Needs learners:read with a key. |
api_key.expiring | An active Content API key of the organization expires within 7 days. Sent once per expiry. |
resource.* events are sent only for successful Content API requests. Edits an educator makes in the TutorFlow editor do not send them, and neither do generation routes, which have their own events. This is changing; see Resource events from any source. Game and simulation events are sent in both streaming and async mode. Generation, game, and simulation events are sent only for runs started through the Content API, one outcome event per run. webhook.ping is sent only by the test route and cannot be subscribed to.
Subscribing an endpoint to a learner.* event with a key needs the learners:read scope, otherwise 403 content_insufficient_scope. Admins subscribing in Settings > Content API are not limited this way. A test endpoint cannot subscribe to learner.* events at all (403 content_sandbox_unsupported).
Live and test endpoints
Every endpoint has a mode, live or test. Live endpoints get events about real content; test endpoints get only events from the sandbox classroom, for every event family, including api_key.expiring for test keys. An endpoint created with an API key takes the key's mode (naming the other one answers 400), and a key sees and manages only endpoints of its own mode. An admin chooses with mode on create (default live) and sees both. Check livemode in your receiver before acting on an event.
Create an endpoint
The default is Settings > Content API in TutorFlow, where you can create an endpoint, copy its secret, send a test event, read the delivery log, and send deliveries again.
To register one from code, use a key with the webhooks:manage scope. A key with a classroom allowlist cannot hold that scope, because webhooks carry events from every classroom.
WEBHOOK_JSON="$(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"]
}')"
export WEBHOOK_ID="$(printf '%s' "$WEBHOOK_JSON" | jq -r '.id')"
export TUTORFLOW_WEBHOOK_SECRET="$(printf '%s' "$WEBHOOK_JSON" | jq -r '.secret')"Response 201:
{
"id": "00000000-0000-4000-8000-000000000801",
"organizationId": "00000000-0000-4000-8000-000000000001",
"url": "https://example.com/tutorflow/webhooks",
"events": ["content.completed", "content.failed", "game.build.completed", "game.build.failed"],
"status": "ACTIVE",
"mode": "live",
"previousSecretExpiresAt": null,
"consecutiveFailures": 0,
"failingSince": null,
"lastSuccessAt": null,
"disabledReason": null,
"disabledAt": null,
"createdAt": "2026-09-30T09:00:00.000Z",
"updatedAt": "2026-09-30T09:00:00.000Z",
"secret": "generated-signing-secret"
}The secret is returned only here and on rotation. Store it with your receiver. To choose your own, send secret (16 to 256 characters). Secrets are encrypted at rest.
The url must use https and resolve to a public internet address. URLs that point at localhost, a private network, or a cloud metadata address return 400. The address is checked again on every delivery, so a URL whose DNS later points somewhere private stops receiving events.
The same routes exist under /v1/content/organizations/{organizationId}/webhooks for an admin session; see Create a key with the API for signing in. Both answer errors in the Content API envelope, and an id path parameter that is not a UUID returns 422 content_invalid_request. Changes made with an admin session are recorded in the audit log with the admin's user id.
Request headers
POST /tutorflow/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: TutorFlow-Content-Webhooks/1.0
X-Content-Integration-Event: content.completed
X-Content-Integration-Event-Id: 00000000-0000-4000-8000-000000000901
X-Content-Integration-Delivery-Id: 00000000-0000-4000-8000-000000000802
X-Content-Integration-Delivery-Attempt: 1
X-Content-Integration-Timestamp: 1790758800
X-Content-Integration-Signature: 5f2b...
X-Content-Integration-Signature-V2: t=1790758800,v1=9a41...| Header | Meaning |
|---|---|
X-Content-Integration-Event | The event type. |
X-Content-Integration-Event-Id | The event id. It stays the same across retries and redeliveries: use it to skip events you already processed. |
X-Content-Integration-Delivery-Id | This delivery. A redelivery gets a new one. |
X-Content-Integration-Delivery-Attempt | The attempt number, starting at 1. |
X-Content-Integration-Timestamp | When this attempt was signed, in Unix seconds. Every retry is signed again. |
X-Content-Integration-Signature-V2 | The signature to verify. See below. |
X-Content-Integration-Signature | Legacy: hex HMAC-SHA256 of the body alone, with the current secret only. It has no timestamp, so a captured request could be replayed. Kept for existing receivers; do not use it in new ones. |
Verify the signature
X-Content-Integration-Signature-V2 has the form t=<timestamp>,v1=<signature>:
tis the Unix time the attempt was signed.v1names the signature scheme, version 1: a hex HMAC-SHA256, keyed with your endpoint secret, of the bytes<t>.followed by the raw request body.- During a secret rotation grace period, the header carries one
v1=per secret, new first:t=1790758800,v1=<new>,v1=<old>. Accept the request if anyv1value matches a secret you hold.
To verify:
- Read the raw body as bytes, before any JSON parsing. Parsing and serializing again changes the bytes and the signature will not match.
- Reject the request if
tis more than 5 minutes from your clock. - Compute HMAC-SHA256 of the bytes
<t>.followed by the raw body, with your secret. In Node.js:Buffer.concat([Buffer.from(`${t}.`), rawBody]). - Compare it with each
v1value in constant time. - Only then parse the JSON.
// Express 4 or 5, Node.js 18+
import crypto from 'node:crypto'
import express from 'express'
const TOLERANCE_SECONDS = 300
export function verifyTutorFlowSignature(rawBody, header, secrets, nowMs = Date.now()) {
if (!Buffer.isBuffer(rawBody) || typeof header !== 'string') return false
let timestamp = NaN
const signatures = []
for (const part of header.split(',')) {
const separator = part.indexOf('=')
const name = part.slice(0, separator).trim()
const value = part.slice(separator + 1).trim()
if (name === 't') timestamp = Number(value)
if (name === 'v1' && value) signatures.push(Buffer.from(value, 'hex'))
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false
if (Math.abs(nowMs / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody])
return secrets.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest()
return signatures.some(
(received) => received.length === expected.length && crypto.timingSafeEqual(received, expected),
)
})
}
// During a rotation, list both secrets: [new, old].
const secrets = [process.env.TUTORFLOW_WEBHOOK_SECRET, process.env.TUTORFLOW_WEBHOOK_SECRET_PREVIOUS].filter(Boolean)
const app = express()
// express.raw keeps req.body as a Buffer for this route only.
app.post('/tutorflow/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Content-Integration-Signature-V2')
if (!verifyTutorFlowSignature(req.body, header, secrets)) {
return res.status(400).send('Invalid signature')
}
const eventId = req.get('X-Content-Integration-Event-Id')
const payload = JSON.parse(req.body.toString('utf8'))
// Record eventId, queue the work, and answer fast. Skip eventIds you have seen.
enqueueTutorFlowEvent(eventId, payload)
res.sendStatus(204)
})// Fastify 4 or 5, Node.js 18+. verifyTutorFlowSignature is the function from the Express example.
import Fastify from 'fastify'
const secrets = [process.env.TUTORFLOW_WEBHOOK_SECRET, process.env.TUTORFLOW_WEBHOOK_SECRET_PREVIOUS].filter(Boolean)
const app = Fastify()
app.register(async (webhooks) => {
// Inside this plugin only, keep JSON bodies as a Buffer.
webhooks.addContentTypeParser('application/json', { parseAs: 'buffer' }, (request, body, done) => {
done(null, body)
})
webhooks.post('/tutorflow/webhooks', async (request, reply) => {
const header = request.headers['x-content-integration-signature-v2']
if (!verifyTutorFlowSignature(request.body, header, secrets)) {
return reply.code(400).send('Invalid signature')
}
const eventId = request.headers['x-content-integration-event-id']
const payload = JSON.parse(request.body.toString('utf8'))
enqueueTutorFlowEvent(eventId, payload)
return reply.code(204).send()
})
})# Python 3.9+ with Flask
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 300
SECRETS = [s for s in (os.environ.get("TUTORFLOW_WEBHOOK_SECRET"), os.environ.get("TUTORFLOW_WEBHOOK_SECRET_PREVIOUS")) if s]
app = Flask(__name__)
def verify_tutorflow_signature(raw_body: bytes, header: str, secrets: list[str]) -> bool:
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
name, _, value = part.strip().partition("=")
if name == "t" and value.isdigit():
timestamp = int(value)
elif name == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed_payload = str(timestamp).encode() + b"." + raw_body
for secret in secrets:
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, received) for received in signatures):
return True
return False
@app.post("/tutorflow/webhooks")
def tutorflow_webhook():
raw_body = request.get_data() # bytes, exactly as received
header = request.headers.get("X-Content-Integration-Signature-V2", "")
if not verify_tutorflow_signature(raw_body, header, SECRETS):
abort(400)
event_id = request.headers.get("X-Content-Integration-Event-Id")
payload = json.loads(raw_body)
enqueue_tutorflow_event(event_id, payload) # record the id, queue the work
return "", 204Request bodies
Every body keeps the event's fields at the top level and adds two fields:
livemode:truefor events about real content,falsefor events from the test mode sandbox. Deliveries queued before 2026-10-01 do not carry it; they are live.event: the event id, type, and creation time.
The examples below leave out livemode for brevity; every delivery carries it.
Expansion events
{
"id": "00000000-0000-4000-8000-000000000030",
"sourceType": "source_json",
"sourceTitle": "Language Basics, A1 Foundations",
"status": "completed",
"requestedOutputs": ["interactive_module", "summary_video", "expanded_quiz"],
"completedAt": "2026-09-30T09:04:12.000Z",
"event": {
"id": "00000000-0000-4000-8000-000000000901",
"type": "content.completed",
"createdAt": "2026-09-30T09:04:12.310Z"
}
}id is the job id. Read the result for the outputs.
content.failed is sent once, after the job's final attempt. A job is tried up to 3 times, and attempts that are tried again send nothing; while that happens the job stays processing. In a rare case, a job interrupted by a server restart right after it finished can send content.completed twice for the same job id, so skip job ids you have already handled, and read the job when its final status matters.
Resource events
{
"resourceType": "video",
"resourceId": "00000000-0000-4000-8000-000000000301",
"classroomId": "00000000-0000-4000-8000-000000000010",
"apiKeyId": "00000000-0000-4000-8000-000000000020",
"request": "PATCH /v1/content/classrooms/:classroomId/videos/:id/scenes/:sceneId",
"event": {
"id": "00000000-0000-4000-8000-000000000902",
"type": "resource.updated",
"createdAt": "2026-09-30T09:10:00.000Z"
}
}resourceType is course, game, module, simulation, slide, test, or video. apiKeyId is the key that made the change, and request is the method and route template.
Resource events from any source
Since 2026-10-01:
resource.created,resource.updated, andresource.deletedare sent for changes made anywhere: through the Content API, and in the TutorFlow app, including the course, video, slide, and test editors, game and simulation builds, and background work such as renders. Content API generation routes send them too, because the data they write changes.- Changes to one resource within about 2 seconds of each other become one event, covering at most 30 seconds of changes. Expect fewer, merged events: a create followed by edits within 2 seconds is one
resource.created. Events arrive a few seconds after the change. - A change to a chapter, lesson, video scene, or test item is a
resource.updatedof its course, video, or test, naming the child. - A soft delete sends
resource.deleted, and restoring a deleted resource sendsresource.created. Changes that no Content API response shows, such asupdatedAtalone, send nothing. - A change to a module's quizzes is a
resource.updatedof the module, and changes the module'sETag. Course lesson quizzes, coding problems, and exam settings are not covered, because no Content API response returns them.
Every existing field stays. These are added:
| Field | Type | Meaning |
|---|---|---|
source | "api" or "app" | Where the latest change in the event came from. |
resourceUpdatedAt | ISO 8601 | When the latest change was saved. |
sequence | integer | The resource's version after this event. It grows with every saved change. Ignore an event whose sequence is lower than one you already applied; retries can deliver events out of order. |
changeCount | integer | How many row changes the event groups. |
subresource | { "type", "id", "op" } | Set when exactly one child changed. type is chapter or lesson (course), scene (video), or item (test). op is created, updated, or deleted. A lesson id is the id the curriculum routes use. |
subresources | array of the above | Set when more than one child changed, in the order they first changed, at most 50. |
subresourcesTruncated | boolean | Sent with subresources. true when more than 50 children changed; read the resource. |
apiKeyId and request become null when the latest change came from the app. When an event groups changes from several sources, source, apiKeyId, and request describe the latest one.
Generation run events
test.generation.*, module.generation.*, course.generation.*, and slide.generation.* carry the 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,
"event": {
"id": "00000000-0000-4000-8000-000000000906",
"type": "course.generation.completed",
"createdAt": "2026-10-01T09:24:10.000Z"
}
}resourceType is test, module, course, or slide, and status is completed or failed. On failure, error holds the reason, and resourceId points to what the run already made, if anything. Each run sends one of these, whichever server finishes it; use runId to skip duplicates.
Game and simulation events
{
"resourceType": "game",
"resourceId": "00000000-0000-4000-8000-000000000601",
"classroomId": "00000000-0000-4000-8000-000000000010",
"runId": "00000000-0000-4000-8000-000000000a01",
"phase": "build",
"status": "completed",
"error": null,
"contentVersion": 3,
"event": {
"id": "00000000-0000-4000-8000-000000000903",
"type": "game.build.completed",
"createdAt": "2026-09-30T09:21:40.000Z"
}
}phase is brief, build, or revise, and status is completed or failed. On failure, error holds the reason, violations lists the checks the build could not pass when there were any, and contentVersion is null. runId is the run's id for every phase, briefs included, streamed or async; it matches the runId of the 202 and of GET .../run.
Every run started through the Content API ends with one of these events, also when the server running it stopped. In one rare case a *.build.completed with the same runId follows a *.build.failed as a correction, when a build reported failed after a restart finished saving in its last seconds; treat the later event as the outcome. A run interrupted that way fails with an error that says so, such as "The generation was interrupted by a server restart. Start it again." See Interrupted runs.
Video render events
{
"resourceType": "video",
"resourceId": "00000000-0000-4000-8000-000000000301",
"classroomId": "00000000-0000-4000-8000-000000000010",
"renderStatus": "COMPLETED",
"videoUrl": "https://cdn.tutorflow.io/orgs/.../videos/rendered/...mp4?Expires=...&Signature=...",
"videoUrlExpiresAt": "2026-09-30T15:30:00.000Z",
"event": {
"id": "00000000-0000-4000-8000-000000000904",
"type": "video.render.completed",
"createdAt": "2026-09-30T09:30:00.000Z"
}
}videoUrl is a signed link to the mp4 that expires at videoUrlExpiresAt. It is signed again just before every delivery attempt, including retries and redeliveries, and videoUrlExpiresAt is 6 hours after that attempt, so even a late retry carries a working link. Download it promptly, or read a new link from the render status. A failed render carries renderStatus: "FAILED" and, instead of the link, error with the fixed message "The render failed. Start it again, or contact TutorFlow support if it keeps failing."
event.id stays the same across attempts, so de-duplication is unaffected; only videoUrl and videoUrlExpiresAt differ between attempts of one event. The signature headers sign exactly the body sent with each attempt, so verification is unchanged. Deliveries created before 2026-09-30 are sent exactly as stored.
Learner events
{
"learnerId": "00000000-0000-4000-8000-000000000f01",
"testId": "00000000-0000-4000-8000-000000000401",
"classroomId": "00000000-0000-4000-8000-000000000010",
"resultId": "00000000-0000-4000-8000-000000000f11",
"submittedAt": "2026-09-30T09:18:02.000Z",
"event": {
"id": "00000000-0000-4000-8000-000000000907",
"type": "learner.test.submitted",
"createdAt": "2026-09-30T09:18:02.400Z"
}
}| Event | Fields |
|---|---|
learner.enrolled | learnerId, courseId, classroomId |
learner.course.completed | learnerId, courseId, classroomId, completedAt |
learner.test.submitted | learnerId, testId, classroomId, resultId, submittedAt |
Learner events carry ids and times only, never names or email addresses. Read the learner with the learner routes, which need learners:read.
Key events
{
"keyId": "00000000-0000-4000-8000-000000000020",
"name": "cms-sync-production",
"keyPrefix": "tf_content_abc123def456",
"expiresAt": "2026-10-06T00:00:00.000Z",
"event": {
"id": "00000000-0000-4000-8000-000000000905",
"type": "api_key.expiring",
"createdAt": "2026-09-30T01:00:03.000Z"
}
}TutorFlow checks once a day for active keys that expire within 7 days and sends api_key.expiring once per expiry, next to an email to the organization's admins. Changing the key's expiresAt re-arms it. Use it to open a ticket or start a rotation before the key stops working. See Expiry warnings.
Receiver checklist
- Verify
X-Content-Integration-Signature-V2against the raw body before doing anything else. - Reject timestamps more than 5 minutes from your clock.
- Answer
2xxwithin 10 seconds. Queue slow work instead of doing it inline. - Check
livemode, so an event from the test sandbox never changes production data. - Store each
X-Content-Integration-Event-Idand skip ids you have seen. The same event can arrive more than once. - Do not rely on order. Retries can deliver an older event after a newer one, and events created in the same instant, such as several resources changed by one save, have no order among themselves. Read the resource if you need its current state.
Rotate the signing secret
POST /v1/content/webhooks/{webhookId}/rotate-secret replaces the secret. Send secret to choose the new one, or omit it to have TutorFlow generate one. The new secret is returned once.
gracePeriodHours (0 to 168) keeps the old secret signing deliveries next to the new one for that many hours. The V2 header then carries both signatures, and the endpoint's previousSecretExpiresAt says when the old one stops. Omitted or 0, the old secret stops at once. Rotating again during a grace period keeps only the secret being replaced; the older one stops.
Zero-downtime rotation:
-
Rotate with a grace period that covers your deploy:
bashcurl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/webhooks/$WEBHOOK_ID/rotate-secret" \ -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "gracePeriodHours": 24 }'Deliveries are now signed
t=...,v1=<new>,v1=<old>, so receivers still holding the old secret keep verifying. -
Deploy the new secret to every receiver. During the deploy, receivers can hold both, as in the examples above (
[new, old]). -
Send a test event and confirm your receiver answers
2xx. -
After
previousSecretExpiresAt, remove the old secret from your receivers.
If the secret may have leaked, rotate with "gracePeriodHours": 0 and deploy the new secret at once; deliveries fail verification until you do, and are retried.
The legacy X-Content-Integration-Signature header is signed with the new secret only, from the moment you rotate. A receiver that still verifies it cannot rotate without downtime; move it to the V2 header first.
Manage endpoints
Every route below is under /v1/content/webhooks with a webhooks:manage key, and under /v1/content/organizations/{organizationId}/webhooks with an admin session.
| Method and path | Purpose |
|---|---|
GET / | List endpoints, as a plain array. Secrets are never included. |
POST / | Create an endpoint. Returns the secret once. |
GET /{webhookId} | Get one endpoint. |
PATCH /{webhookId} | Change url, events, or status (ACTIVE or DISABLED). DISABLED pauses deliveries without deleting the endpoint; its pending retries are marked FAILED as they come due. ACTIVE turns it back on, and replayFailedSince sends failed events again; see Turn an endpoint back on. |
POST /{webhookId}/rotate-secret | Replace the signing secret. See above. |
POST /{webhookId}/test | Send a webhook.ping now and return the delivery. |
GET /{webhookId}/deliveries | The delivery log, newest first. |
POST /{webhookId}/deliveries/{deliveryId}/redeliver | Send a past delivery again now. |
DELETE /{webhookId} | Delete the endpoint. Returns 200 with { "id", "deleted": true }. |
Every endpoint object, in lists and in single responses, carries its health.
Management responses carry the same headers as other Content API responses, including Request-Id. A browser client can read Request-Id, the X-RateLimit-* headers, Retry-After, Idempotent-Replayed, Deprecation, Sunset, and Link: the Content API lists them in Access-Control-Expose-Headers. Keep tf_content_ keys out of browsers all the same; call the API from your server.
Send a test event
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/webhooks/$WEBHOOK_ID/test" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"The ping goes to this endpoint whatever events it subscribes to. Its body is { "endpointId", "message", "livemode", "event" }, with the livemode of the endpoint. The response is the delivery record, so you see at once whether your receiver answered 2xx. A failed ping is reported, not retried.
Delivery log
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/webhooks/$WEBHOOK_ID/deliveries?status=FAILED&limit=20" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"| Parameter | Meaning |
|---|---|
status | PENDING, SENT, RETRYING, or FAILED. |
eventType | For example game.build.completed. |
before | Only deliveries created before this ISO 8601 time. To get the next page, pass the createdAt of the last delivery on the current page. |
limit | 1 to 100. Default 20. |
The response is a plain array of deliveries:
[
{
"id": "00000000-0000-4000-8000-000000000802",
"eventId": "00000000-0000-4000-8000-000000000901",
"eventType": "content.completed",
"status": "RETRYING",
"attempts": 2,
"responseStatus": 503,
"lastError": "HTTP 503",
"lastAttemptAt": "2026-09-30T09:06:42.000Z",
"nextRetryAt": "2026-09-30T09:16:42.000Z",
"sentAt": null,
"createdAt": "2026-09-30T09:04:12.000Z",
"payload": { "id": "00000000-0000-4000-8000-000000000030", "event": { "type": "content.completed" } }
}
]payload is the exact body last sent, so for video.render.completed it holds the latest signed videoUrl. It is null in delivery, test, and redeliver responses for a key without content:read. Delivery records are kept for 30 days.
Redeliver
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/redeliver" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"A redelivery is a new delivery, with a new delivery id and the same event id and body, sent now. A video.render.completed redelivery carries a newly signed videoUrl. Use it to catch up after your receiver dropped events.
Delivery
Events are sent in the background, usually within about a second of being created. The response to the request that caused an event does not wait for it.
- Each endpoint receives its deliveries one at a time, oldest first, so a slow receiver slows only its own endpoint.
- After a failed attempt, TutorFlow leaves the endpoint alone for about 30 seconds, and newer events for it wait behind the failed one. A receiver that is down therefore gets its backlog in order once it answers again.
- Longer delays happen: when an endpoint has a backlog, just after a failure, or when a TutorFlow server stops mid-delivery (up to a few minutes).
Retries
A delivery succeeds when your receiver answers 2xx within 10 seconds. Anything else, including 4xx, 5xx, a timeout, or a connection error, is retried.
- Up to 9 attempts over about 23 hours. The waits between attempts are 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, and 12 hours. After the last attempt the delivery is
FAILED, and you can still redeliver it. 307and308redirects are followed, up to 3 hops, and each target must also be a public address.301,302, and303are not followed, because they would turn the POST into a GET; the attempt fails with alastErrorthat asks you to register the final URL.
Endpoint health
Every endpoint object carries these fields:
| Field | Meaning |
|---|---|
consecutiveFailures | Failed delivery attempts in a row since the last success. Test pings and manual redeliveries do not count. |
failingSince | When the current run of failures began, or null while deliveries succeed. |
lastSuccessAt | The last successful delivery, or null. |
disabledReason | Why the endpoint is DISABLED: manual (someone set it to DISABLED) or repeated_failures (TutorFlow turned it off). null while ACTIVE. Endpoints disabled before 2026-10-01 show manual. |
disabledAt | When the endpoint was turned off, or null. |
Watch consecutiveFailures and failingSince from your own monitoring, or in Settings > Content API, which shows a health badge per endpoint.
Endpoints that keep failing are turned off
TutorFlow turns an endpoint off when both are true: it has failed at least 20 delivery attempts in a row, and it has been failing for at least 3 days. Then:
- its
statusbecomesDISABLED, withdisabledReason: "repeated_failures"; - deliveries still waiting are marked
FAILED, withlastError"The webhook endpoint is disabled"; - new events are not queued for it;
- every active admin of the organization gets an email, in their TutorFlow language, with a link to the Webhooks tab of Settings > Content API. No webhook is sent about it, since the endpoint is the one that fails.
Turn an endpoint back on
PATCH the endpoint with status: "ACTIVE". To also send again what failed while it was down, add replayFailedSince:
curl -sS -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "ACTIVE", "replayFailedSince": "2026-09-28T00:00:00Z" }'The response is the endpoint plus replayedDeliveries, the number of events queued again (null when you did not ask for a replay). Settings > Content API has a Turn back on button that does the same.
status: "ACTIVE"on aDISABLEDendpoint resetsconsecutiveFailuresto 0 and clearsfailingSince,disabledReason, anddisabledAt. Sending it to an endpoint that is alreadyACTIVEchanges nothing.status: "DISABLED"recordsdisabledReason: "manual"anddisabledAt.replayFailedSincequeues again every event created since that time whose delivery to this endpoint endedFAILED, as a new delivery with the same event id and body, one per event. Test pings, events that were later delivered or are still queued, and older events are skipped. It must be at most 7 days ago and not in the future, at most 10,000 events are queued per request, and the endpoint must end upACTIVE, already or throughstatus: "ACTIVE"in the same request. Otherwise the answer is400content_invalid_request, for example "replayFailedSince may be at most 7 days ago".
Replayed deliveries keep their event ids, so a receiver that skips ids it has seen processes each event once.