Every route that reads or changes one resource answers with an ETag header, a tag for the version of the resource you just read or wrote (a DELETE answer has none, since the resource is gone). Send it back to:
- skip unchanged reads with
If-None-Match: TutorFlow answers304 Not Modifiedwith no body when nothing changed; - protect a write with
If-Match: TutorFlow applies aPATCHorDELETEonly if the resource is still at that version, and answers412otherwise.
Both are optional. A request without them behaves as it always has: reads return the full body, and the last write wins.
Which routes carry an ETag
Every path below is under /v1/content/classrooms/{classroomId}.
| Resource | Routes | Tag of |
|---|---|---|
| Course | GET, PATCH, DELETE /courses/{courseId} | The course |
| Course curriculum | GET /courses/{courseId}/chapters, PATCH /courses/{courseId}/chapters/reorder, PATCH and DELETE /courses/{courseId}/chapters/{chapterId}, PATCH /courses/{courseId}/chapters/{chapterId}/lessons/reorder, GET, PATCH, and DELETE /courses/{courseId}/lessons/{lessonId} | The course |
| Module | GET, PATCH, DELETE /modules/{moduleId} | The module |
| Video | GET, PATCH, DELETE /videos/{videoId}, PATCH /videos/{videoId}/scenes/reorder, PATCH and DELETE /videos/{videoId}/scenes/{sceneId} | The video |
| Slides | GET, PATCH, DELETE /slides/{slideId} | The deck |
| Test | GET, PATCH, DELETE /tests/{testId} | The test, including its items |
| Game, simulation | GET, PATCH, DELETE /games/{gameId} and /simulations/{simulationId} | The game or simulation |
Lists, every POST (creates, scene and chapter and lesson creates, builds, revisions, restores, briefs, renders, narration, copy to course), and run and render status routes are not versioned, and If-Match is ignored on them. Their responses, and DELETE responses, can carry a weak W/"..." ETag that the web server computes from the response body. It is not a resource version: do not send it in If-Match.
A course's tag covers its chapters and lessons, a video's covers its scenes, and a test's covers its items. So chapter, lesson, and scene routes return the tag of their course or video, and If-Match on them takes that parent tag. A change to any lesson changes the course's tag.
What the tag looks like
ETag: "v12"The tag is a strong ETag and opaque. Today it looks like "v12", or "u1790726400000" for a resource that nobody has changed since versioning started, but that format is not part of the contract. Do not parse it, compare it for order, or build one yourself: store the header value exactly, quotes included, and send it back byte for byte.
- Every saved change moves the tag, whether it came from the Content API or from an educator in the TutorFlow editor.
- A successful
PATCHanswers with the tag after the change. Use it for your nextIf-Match. - A
DELETEanswer carries no resource version. Ignore any weakW/"..."tag on it.
Skip unchanged reads
Send the tag you hold in If-None-Match. If the resource is still at that version, the answer is 304 Not Modified with no body:
curl -i "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H 'If-None-Match: "v12"'HTTP/1.1 304 Not Modified
ETag: "v12"
Request-Id: 3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 41- Send the tag exactly as you received it, quotes included. The comparison is weak, so
W/"v12"matches"v12"too, but an unquotedv12does not match and gets200.*matches any resource that exists. - A
304keeps theETag,Request-Id, and rate-limit headers. - A
304still counts against the key's rate limit. - When the tag no longer matches, the answer is a normal
200with the body and the newETag.
fetch() needs Cache-Control: max-age=0
Node.js fetch (undici) and browser fetch add Cache-Control: no-cache on their own to any request that carries a conditional header such as If-None-Match. HTTP caching rules say a server must not answer 304 to a no-cache request, so with fetch you always get 200 and the full body. curl does not add the header, so it gets the 304.
To get 304 from fetch, send Cache-Control: max-age=0 yourself, next to If-None-Match:
curl -sS -o /dev/null -w '%{http_code}\n' \
"$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "If-None-Match: $COURSE_ETAG"
# 304 when the course is unchanged, 200 otherwise// Node.js 18+
const url = `${process.env.TUTORFLOW_API_BASE_URL}/v1/content/classrooms/${process.env.CLASSROOM_ID}/courses/${courseId}`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.TUTORFLOW_CONTENT_API_KEY}`,
'If-None-Match': storedETag,
// Without this, fetch sends Cache-Control: no-cache and the answer is always 200.
'Cache-Control': 'max-age=0',
},
})
if (response.status === 304) {
console.log('unchanged since', storedETag)
} else {
const course = await response.json()
storedETag = response.headers.get('etag')
console.log('changed:', course.title, storedETag)
}Protect a write
Send the tag you read in If-Match on a PATCH or DELETE. The change is made only if the resource is still at that version:
curl -i -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H 'If-Match: "v12"' \
-d '{ "title": "Customer onboarding, v2" }'If someone changed the resource since you read it, nothing is written and the answer is 412:
HTTP/1.1 412 Precondition Failed{
"error": {
"code": "content_precondition_failed",
"message": "The resource changed since the version named in If-Match. Fetch it again and retry with its current ETag",
"currentETag": "\"v13\"",
"status": 412,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}If-Match: *means "only if the resource exists".- The comparison is strong: a weak tag such as
W/"v12"never matches. - If the resource does not exist, or your key cannot see it, a request with
If-Matchgets412withcurrentETag: nulland the message "If-Match was sent, but the resource does not exist or this key cannot see it", not404. WithoutIf-Match, the404stays. - Two conditional writes from the same tag never both succeed: writes to one resource are serialized, so the second gets
412.
The read, change, write loop
GETthe resource and keep itsETag.- Apply your change to what you read.
PATCHwithIf-Matchset to that tag.- On
412, go back to step 1: read the resource again, apply your change to the new version, and retry. Do not resend the old body withcurrentETag; that would overwrite the change you were warned about.
COURSE_URL="$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID"
AUTH="Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
# 1. Read, keeping the ETag header
COURSE_ETAG="$(curl -sS -D - -o /dev/null "$COURSE_URL" -H "$AUTH" | awk 'tolower($1) == "etag:" { print $2 }' | tr -d '\r')"
# 2 and 3. Write only if the course is still at that version
HTTP_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PATCH "$COURSE_URL" -H "$AUTH" \
-H "Content-Type: application/json" -H "If-Match: $COURSE_ETAG" \
-d '{ "title": "Customer onboarding, v2" }')"
# 4. 412 means someone changed it: read again before retrying
echo "$HTTP_STATUS"import { requestWithMeta, TutorFlowError } from './tutorflow.js'
const MAX_TRIES = 3
// Retries a change on 412 by reading the resource again and reapplying it.
export async function updateWithIfMatch(path, applyChange) {
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
const read = await fetch(`${process.env.TUTORFLOW_API_BASE_URL}${path}`, {
headers: { Authorization: `Bearer ${process.env.TUTORFLOW_CONTENT_API_KEY}` },
})
if (!read.ok) throw new Error(`GET ${path} answered ${read.status}`)
const etag = read.headers.get('etag')
const current = await read.json()
try {
const { body } = await requestWithMeta('PATCH', path, {
body: applyChange(current),
headers: { 'If-Match': etag },
})
return body
} catch (error) {
if (error instanceof TutorFlowError && error.status === 412) continue
throw error
}
}
throw new Error(`${path} kept changing; gave up after ${MAX_TRIES} tries`)
}
const coursePath = `/v1/content/classrooms/${process.env.CLASSROOM_ID}/courses/${courseId}`
await updateWithIfMatch(coursePath, (course) => ({ title: `${course.title} (reviewed)` }))The Node.js example uses requestWithMeta() from the request wrapper, which raises 412 as a TutorFlowError without retrying it.
What If-Match does not cover
If-Match protects a write only against writers that also use it, and against every change that landed before your write started. A write that does not send If-Match, from another integration or from the TutorFlow editor, can still land between TutorFlow's check and your write, and be overwritten by it. When several systems edit the same content, ask every one of them to send If-Match.
The tag is not a timestamp and has no order a client can use. Do not compare two tags to decide which is newer; read the resource instead. resource.* webhook events will carry a sequence for ordering once resource events from any source are switched on.
Browser clients
ETag is listed in Access-Control-Expose-Headers, so browser code can read it, and the CORS preflight allows the If-Match and If-None-Match request headers. Keep tf_content_ keys out of browsers all the same: call the API from your server.