npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tutorflow/content-api-sdk

v0.1.0

Published

TypeScript client for the TutorFlow Content API: typed routes, retries, pagination, run polling, ETags and webhook verification.

Readme

@tutorflow/content-api-sdk

A TypeScript client for the TutorFlow Content API (/v1/content). Every route and response is typed from the published OpenAPI document, and the helpers cover what an integration otherwise writes by hand: retries, idempotency keys, pagination, waiting for runs, ETags, and webhook signatures.

Install

Requires Node.js 20 or later.

pnpm add @tutorflow/content-api-sdk

The package is ESM only and ships its type declarations in dist/. Documentation is at tutorflow.io/resources/integrations/content-api-sdks.

Create a client

Keep the key on your server. Never send it to a browser.

import { createClient, unwrap } from '@tutorflow/content-api-sdk'

const client = createClient({ apiKey: process.env.TUTORFLOW_CONTENT_API_KEY! })

const classrooms = unwrap(await client.GET('/v1/content/classrooms'))

Calls resolve to { data, error, response } as in openapi-fetch. unwrap() returns data or throws a ContentApiError with code, status, requestId, retryAfterSeconds, currentETag and details from the error envelope. A 412 throws its subclass ContentPreconditionFailedError.

A test key (tf_content_test_...) works the same way against the sandbox classroom; isTestKey(apiKey) tells them apart.

Retries and idempotency

The client retries on its own:

  • 429: always, after Retry-After (up to 60 seconds; a longer wait is returned to you as the 429).
  • 500, 502, 503, 504 and network failures: only for GET, PUT, DELETE and requests with an Idempotency-Key, so a change is never applied twice.

Backoff starts at 500 ms and doubles with jitter, two retries by default. Tune it with createClient({ retry: { maxRetries, baseDelayMs, maxDelayMs, maxRetryAfterMs } }) or turn it off with retry: false.

withIdempotencyKey() adds an Idempotency-Key (a new UUID unless you pass one). Every retry sends the same key, and the API replays the first answer instead of acting again:

import { withIdempotencyKey } from '@tutorflow/content-api-sdk'

const run = unwrap(
  await client.POST(
    '/v1/content/classrooms/{classroomId}/courses/generate',
    withIdempotencyKey({
      params: { path: { classroomId } },
      body: { topic: 'Fractions for Grade 5', lessonCount: 6, language: 'en' },
    }),
  ),
)

Pagination

paginate() yields every item of a list. It reads both envelopes: page-numbered lists (meta.page, meta.hasNextPage) and cursor lists (meta.nextCursor). Spread the next argument into the query and keep the other filters the same:

import { paginate } from '@tutorflow/content-api-sdk'

for await (const course of paginate((next) =>
  client.GET('/v1/content/classrooms/{classroomId}/courses', {
    params: { path: { classroomId }, query: { ...next, limit: 100 } },
  }),
)) {
  console.log(course.id, course.title)
}

Waiting for runs

waitForRun() polls until a run settles and returns its final status. It does not throw when the run failed; check the status it returns.

import { waitForRun } from '@tutorflow/content-api-sdk'

// Test, module, course and slide generation: GET .../runs/{runId}
const finished = await waitForRun(client, { type: 'generation', classroomId, runId: run.runId })
if (finished.status === 'failed') throw new Error(finished.error ?? 'Generation failed')

// Game or simulation brief, build, revise: GET .../{games|simulations}/{id}/run
const build = await waitForRun(client, { type: 'build', kind: 'game', classroomId, resourceId: gameId, runId })

// Video render: GET .../videos/{id}/render
const render = await waitForRun(client, { type: 'render', classroomId, videoId })

Options: intervalMs (default 3 s), timeoutMs (default 30 minutes), signal, onPoll. The build status route only knows runs of the server that answers since its last deploy; when a run is no longer tracked the wait throws ContentWaitError with reason: 'run_not_tracked', and the run's webhook is the signal to rely on. Webhooks are the better choice wherever you can receive them.

ETags

import { getIfChanged, updateIfMatch, ContentPreconditionFailedError } from '@tutorflow/content-api-sdk'

const read = await getIfChanged(savedETag, ({ headers }) =>
  client.GET('/v1/content/classrooms/{classroomId}/courses/{id}', { params: { path: { classroomId, id } }, headers }),
)
if (!read.notModified) save(read.data, read.etag)

try {
  await updateIfMatch(read.etag!, ({ headers }) =>
    client.PATCH('/v1/content/classrooms/{classroomId}/courses/{id}', {
      params: { path: { classroomId, id } },
      body: { title: 'Fractions for Grade 5 (2026)' },
      headers,
    }),
  )
} catch (error) {
  if (error instanceof ContentPreconditionFailedError) {
    // Someone changed the course since the read: read it again and re-apply.
  }
}

getIfChanged() sends If-None-Match together with Cache-Control: max-age=0. fetch() adds Cache-Control: no-cache to conditional requests by itself, and a server never answers 304 to that, so without the explicit header you would always get the full body back.

Webhooks

verifyWebhook() checks the X-Content-Integration-Signature-V2 header against the raw body and returns the parsed event, typed by event type, with livemode:

import express from 'express'
import { isWebhookEventType, verifyWebhook, WebhookVerificationError } from '@tutorflow/content-api-sdk'

const app = express()

app.post('/tutorflow/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = await verifyWebhook({
      payload: req.body, // the raw Buffer, before JSON parsing
      header: req.get('X-Content-Integration-Signature-V2'),
      secret: [process.env.TUTORFLOW_WEBHOOK_SECRET!, process.env.TUTORFLOW_WEBHOOK_SECRET_PREVIOUS ?? ''],
    })

    if (isWebhookEventType(event, 'course.generation.completed') && event.livemode) {
      await queueCourseImport(event.event.id, event.resourceId)
    }

    res.sendStatus(204)
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason)
    throw error
  }
})

It accepts the request when t is within toleranceSeconds (default 300) of now and any v1= signature matches any secret you pass. During TutorFlow's secret rotation grace period the header carries a signature for the new and the previous secret, so receivers keep passing whichever secret they hold. Deliveries are signed again on every retry.

Types

paths, components, operations and webhooks are exported, and Schema<'ContentCourse'> names one schema.

Development

pnpm --filter @tutorflow/content-api-sdk test             # node:test with a fake fetch
pnpm --filter @tutorflow/content-api-sdk typecheck
pnpm --filter @tutorflow/content-api-sdk build            # tsc to dist/, ESM and .d.ts
pnpm --filter @tutorflow/content-api-sdk generate         # rewrite src/generated/openapi.ts
pnpm --filter @tutorflow/content-api-sdk check:generated  # fail when the types are stale

src/generated/openapi.ts is generated with openapi-typescript from apps/frontend-admin/public/resources/integrations/openapi.json. That file comes from the backend: run pnpm run openapi:content -- <path to openapi.json> in tutorflow-backend, then generate here, and commit both.