Resources
Concurrency and ETags

Concurrency and ETags

Use ETag, If-None-Match, and If-Match to skip unchanged reads and to stop two writers, or an integration and an educator, from overwriting each other's changes.

On this page

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 answers 304 Not Modified with no body when nothing changed;
  • protect a write with If-Match: TutorFlow applies a PATCH or DELETE only if the resource is still at that version, and answers 412 otherwise.

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}.

ResourceRoutesTag of
CourseGET, PATCH, DELETE /courses/{courseId}The course
Course curriculumGET /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
ModuleGET, PATCH, DELETE /modules/{moduleId}The module
VideoGET, PATCH, DELETE /videos/{videoId}, PATCH /videos/{videoId}/scenes/reorder, PATCH and DELETE /videos/{videoId}/scenes/{sceneId}The video
SlidesGET, PATCH, DELETE /slides/{slideId}The deck
TestGET, PATCH, DELETE /tests/{testId}The test, including its items
Game, simulationGET, 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

HTTP
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 PATCH answers with the tag after the change. Use it for your next If-Match.
  • A DELETE answer carries no resource version. Ignore any weak W/"..." 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:

bash
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
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 unquoted v12 does not match and gets 200. * matches any resource that exists.
  • A 304 keeps the ETag, Request-Id, and rate-limit headers.
  • A 304 still counts against the key's rate limit.
  • When the tag no longer matches, the answer is a normal 200 with the body and the new ETag.

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:

bash
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

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:

bash
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
HTTP/1.1 412 Precondition Failed
JSON
{
  "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-Match gets 412 with currentETag: null and the message "If-Match was sent, but the resource does not exist or this key cannot see it", not 404. Without If-Match, the 404 stays.
  • 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

  1. GET the resource and keep its ETag.
  2. Apply your change to what you read.
  3. PATCH with If-Match set to that tag.
  4. 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 with currentETag; that would overwrite the change you were warned about.
bash
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"

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.

Was this page helpful?