Edit

Typed Requests & Errors

Any endpoint, typed

lh.GET, lh.POST, lh.PUT, lh.PATCH and lh.DELETE take an API path exactly as the API reference lists it. Paths autocomplete, parameters and bodies are checked, and the result is typed from the endpoint's response schema.

// Path and query parameters
const meta = await lh.GET('/api/v1/courses/{course_uuid}/meta', {
  params: { path: { course_uuid: 'course_…' }, query: { slim: true } },
})

// JSON body
const chapter = await lh.POST('/api/v1/chapters/', {
  body: { name: 'Week 2', course_id: 12, org_id: 1 },
})

A missing path parameter, a wrong type or an unknown path is a compile error. Calls return the response data directly, or throw.

Files and forms

Pass FormData as the body for uploads, or bodyType: 'form' to send a plain object as form fields:

const form = new FormData()
form.append('thumbnail', file)
await lh.PUT('/api/v1/courses/{course_uuid}/thumbnail', {
  params: { path: { course_uuid: 'course_…' } },
  body: form,
})

Other response formats

parseAs reads the body as 'json' (default for JSON), 'text', 'blob', 'arrayBuffer', 'stream', or gives you the raw 'response'.

Endpoints outside the schema

lh.request(method, path, options) sends any request with the same auth, retries and errors, without types.

Errors

Every failure throws a LearnHouseError:

import { LearnHouseError, isLearnHouseError } from '@learnhouse/sdk'

try {
  await lh.activities.get('activity_…')
} catch (err) {
  if (isLearnHouseError(err)) {
    console.log(err.status) // 404
    console.log(err.code)   // 'HTTP_ERROR', or the API's own code like 'RATE_LIMITED'
    console.log(err.detail) // the API's `detail`
  } else {
    throw err
  }
}
Field
statusHTTP status (0 when there was no response)
codeHTTP_ERROR, NETWORK_ERROR, TIMEOUT, PARSE_ERROR, VALIDATION_ERROR, or the code the API sent
detailThe API's detail: a message, an object, or a list of validation errors
method, path, urlThe request that failed
body, causeThe raw error body, and the underlying error for network failures
retryAfterMsHow long the API asked you to wait, when it sent Retry-After (on 429 and 503)

isLearnHouseError also recognises errors from another copy of the SDK (two bundles on one page).

Timeouts, retries, cancellation

const lh = createClient({
  baseUrl,
  timeoutMs: 15_000, // per attempt, body included. 0 turns it off
  retry: 3,          // or { retries, baseDelayMs, maxDelayMs }. false turns it off
})

// Per call
const controller = new AbortController()
await lh.courses.getMeta('course_…', { signal: controller.signal, timeoutMs: 5_000, retry: false })

The SDK retries:

  • GET and HEAD requests that hit a network error, a timeout or a 5xx.
  • Any request that gets a 429 Too Many Requests. The API refused it before doing anything, so sending it again is safe.

Retries back off exponentially, starting at baseDelayMs (300 ms) and capped at maxDelayMs (5 s). Other requests that change data are never retried, so nothing is applied twice.

Aborting stops the request, and any wait between retries, and throws the platform's AbortError, not a LearnHouseError.

Rate limits

When a 429 or 503 carries a Retry-After header, the SDK waits exactly that long before the next attempt, instead of its own backoff. Both forms work: seconds (Retry-After: 30) and an HTTP date.

If the API asks for a longer wait than maxDelayMs, the SDK does not retry early, since that only earns another 429. It throws right away, and err.retryAfterMs tells you when to try again:

try {
  await lh.search.org('your-school', 'payouts')
} catch (err) {
  if (isLearnHouseError(err) && err.status === 429) {
    console.log(`Try again in ${err.retryAfterMs ?? 1000} ms`)
  } else {
    throw err
  }
}

Raise maxDelayMs if your code can afford to wait longer, for example in a background job.

Pagination

lh.courses.listAll() walks every page for you. Use it with for await:

for await (const course of lh.courses.listAll('your-school')) {
  console.log(course.name)
}

It asks for 100 courses per request (the API's maximum) and stops after the first page that comes back short. It never requests a page past the end. break out of the loop and no further page is fetched.

For other endpoints that take a page and a limit, use paginate(). Give it a function that fetches one page:

import { paginate } from '@learnhouse/sdk'

const controller = new AbortController()
const pages = paginate(
  ({ page, pageSize, signal }) => lh.courses.list('your-school', { page, limit: pageSize, signal }),
  { pageSize: 50, signal: controller.signal },
)

for await (const course of pages) {
  if (course.name === 'Payouts') break
}
OptionDefault
pageSize20Items per request. Keep it at or under the endpoint's maximum, or a capped page reads as the last one
startPage1First page to fetch
signalStops between pages. Pass it to your own call too, as above

Search results are paged per result type, so they have their own iterator. See Search.

Hooks

onRequest runs before each attempt and can change the request; onResponse sees every response.

const lh = createClient({
  baseUrl,
  token,
  onRequest: (ctx) => {
    ctx.init.headers.set('X-Request-Id', crypto.randomUUID())
  },
  onResponse: (ctx) => {
    metrics.timing('learnhouse.request', ctx.path, ctx.response.status)
  },
})

ctx.path is the path template (/api/v1/courses/{course_uuid}), which makes it a good metrics label.

Custom fetch

Pass fetch to use your own implementation: a proxy-aware one in Node, Next.js's cached fetch, or a mock in tests.

const lh = createClient({ baseUrl, token, fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 60 } }) })

Types come from the API of the release the SDK was published with. If your instance is older, a few newer endpoints may not exist on it yet; see Versions & releases.