Two read routes answer the questions that come up once an integration runs in production: what did this key spend, and what did it change?
| Method | Path | Scope | Returns |
|---|---|---|---|
GET | /v1/content/credits/history | content:read | AI Credit charges, refunds, and adjustments, newest first. |
GET | /v1/content/audit-log | content:read | Changes made with keys and by admins, newest first. |
An admin signed in to TutorFlow reads the same data at /v1/content/organizations/{organizationId}/credits/history and /v1/content/organizations/{organizationId}/audit-log, with the same query parameters and responses. Settings > Content API shows both. For signing in, see Create a key with the API.
Who sees which rows
| Caller | Sees |
|---|---|
A full-access key: no classroom allowlist, and either no scope list or all of content:read, content:write, content:generate, and webhooks:manage | Every row of the organization, including rows from the TutorFlow app and from other keys. Narrow with apiKeyId. |
| A test key | Only its own rows, even with every scope. Its credit history is always empty, because test mode never spends. |
| Any other key | Only its own rows. Sending its own apiKeyId works; another key's id answers 403 content_forbidden. |
| An admin session | Every row of the organization. |
A full-access key already can act on everything in the organization and read its balance, so it can also read everyone's history. learners:read is not needed for either route.
Paging
Both routes page with a cursor, newest first by createdAt, then id:
| Parameter | Notes |
|---|---|
limit | Default 20. Values above 100 are clamped to 100. |
cursor | meta.nextCursor from the previous page. Send the same filters with it. |
from | ISO 8601 date-time. Rows at or after it. |
to | ISO 8601 date-time. Rows before it. |
apiKeyId | Rows of one key. |
{
"data": [],
"meta": { "limit": 20, "hasNextPage": true, "nextCursor": "eyJjIjoiMjAyNi0xMC0wMVQwOTowMDowMC4wMDBaIn0" }
}Keep requesting with cursor set to meta.nextCursor while meta.hasNextPage is true; nextCursor is null on the last page. Rows written while you page do not shift your pages. A cursor that is malformed, or from another list or another set of filters, answers 422 content_invalid_request with a details entry for cursor: start again without it. These routes have no page, items, totalCount, or take.
Credit history
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/credits/history?limit=50&from=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"import { request } from './tutorflow.js'
// Sums what the calling key spent this UTC month, net of refunds.
async function spentThisMonth() {
const now = new Date()
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString()
let cursor = null
let spent = 0
do {
const query = new URLSearchParams({ limit: '100', from, ...(cursor ? { cursor } : {}) })
const page = await request('GET', `/v1/content/credits/history?${query}`)
for (const row of page.data) {
if (row.kind === 'charge') spent += row.amount
if (row.kind === 'refund') spent -= row.amount
}
cursor = page.meta.hasNextPage ? page.meta.nextCursor : null
} while (cursor)
return Math.max(spent, 0)
}
console.log('spent this month:', await spentThisMonth()){
"data": [
{
"id": "00000000-0000-4000-8000-000000000c01",
"createdAt": "2026-10-01T10:00:00.000Z",
"type": "CREATE_GAME_BUILD",
"kind": "charge",
"amount": 40,
"note": null,
"classroomId": "00000000-0000-4000-8000-000000000010",
"apiKeyId": "00000000-0000-4000-8000-000000000020",
"apiKey": { "id": "00000000-0000-4000-8000-000000000020", "name": "cms-sync-production", "keyPrefix": "tf_content_abc123def456" }
}
],
"meta": { "limit": 50, "hasNextPage": false, "nextCursor": null }
}| Field | Meaning |
|---|---|
type | What the row is for, for example CREATE_GAME_BUILD, CREATE_VIDEO_RENDER, CREATE_TEST, REFUND, or PURCHASE. |
kind | charge (spent on an AI action), refund (given back for work that failed), or adjustment (purchases, grants, plan recharges, and corrections). |
amount | Always positive. kind gives the direction. |
note | Extra context, or null. A course generation outline carries Content API generation run {runId}: course outline. |
classroomId | The classroom the action ran in, or null. |
apiKeyId, apiKey | The key that made the charge, with its name and keyPrefix. null for rows from the TutorFlow app. A deleted key is still named. Rows from before 2026-10-01 have no apiKeyId. |
Filter with type, one of the organization credit types: CREATE_COURSE, UPDATE_COURSE, CREATE_LESSON, UPDATE_LESSON, TRANSLATE_LESSON, OCR_SCAN, CREATE_IMAGE, CREATE_QUIZZES, CREATE_PROBLEMS, CREATE_AUDIO, CREATE_TEST, CREATE_CURRICULUM, CREATE_SLIDE, TOOL_COMPARE_AI, TOOL_VIDEO_GENERATION, DETECT_AI_GENERATED, TOOL_ASSIGNMENT_FEEDBACK, CREATE_VIDEO_SCRIPT, CREATE_VIDEO_SCRIPT_SPLIT, CREATE_VIDEO_TTS, CREATE_VIDEO_CLIP, CREATE_VIDEO_RENDER, CREATE_SLIDE_OUTLINE, CREATE_GAME_BRIEF, CREATE_GAME_BUILD, CREATE_GAME_REVISION, CREATE_SIMULATION_BRIEF, CREATE_SIMULATION_BUILD, CREATE_SIMULATION_REVISION, CREATE_SIMULATION_BUILD_3D, CREATE_SIMULATION_REVISION_3D, PROMO_CODE, PROMO_CODE_REVOCATION, PURCHASE, MONTHLY_RECHARGE, YEARLY_RECHARGE_BY_MONTH, REFUND, CANCELLATION, FAILED_OVERDRAFT_COLLECTION, or EMAIL_VERIFICATION. Any other value answers 422.
Only charges made with a key are attributed to it. What people do in the TutorFlow app never counts toward a key, even when the same person owns the key. To limit what a key can spend, set a monthly credit limit.
Audit log
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/audit-log?resourceType=course&limit=50" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"import { request } from './tutorflow.js'
const query = new URLSearchParams({ resourceType: 'course', limit: '50' })
const page = await request('GET', `/v1/content/audit-log?${query}`)
for (const row of page.data) {
const who = row.apiKey?.name ?? (row.actorUserId ? 'Admin' : 'unknown')
console.log(row.createdAt, who, row.method, row.route, row.statusCode, row.requestId)
}{
"data": [
{
"id": "00000000-0000-4000-8000-000000000d01",
"createdAt": "2026-10-01T09:12:00.000Z",
"apiKeyId": "00000000-0000-4000-8000-000000000020",
"apiKey": { "id": "00000000-0000-4000-8000-000000000020", "name": "cms-sync-production", "keyPrefix": "tf_content_abc123def456" },
"actorUserId": null,
"classroomId": "00000000-0000-4000-8000-000000000010",
"method": "PATCH",
"route": "/v1/content/classrooms/:classroomId/courses/:id",
"resourceType": "course",
"resourceId": "00000000-0000-4000-8000-000000000201",
"statusCode": 412,
"idempotencyKey": null,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11",
"deprecatedFields": []
}
],
"meta": { "limit": 50, "hasNextPage": false, "nextCursor": null }
}| Field | Meaning |
|---|---|
apiKeyId, apiKey | The key that sent the request, or null for an admin's change in Settings. |
actorUserId | The admin who changed a key or a webhook in Settings or with an admin session, or null. |
actorName | Admin session route only: that admin's full name, or their username when they have none, or null. The key route never names staff. |
method, route | The HTTP method and the route template. |
resourceType, resourceId | What was touched: course, test, module, slide, video, game, simulation, learner, webhook, or api_key. |
statusCode | The response status. Failed changes are recorded too. |
idempotencyKey | The Idempotency-Key sent, or null. |
requestId | The Request-Id of the response. Quote it to support. |
deprecatedFields | Deprecated fields the response carried, for example items. |
Filter with resourceType and classroomId, besides the paging parameters above.
What is recorded
- Every request with a key that changes something (every method other than
GET), whether it succeeded or failed. - Every request to a learner route, reads included.
- Key and webhook changes an admin makes in Settings or with an admin session.
- Reads that received a deprecated field, sampled about once an hour per key and route, so you can find the code that still reads them.
Other reads are not recorded. Rows are kept for 90 days.