@peerfold/api-client
v1.2.0
Published
Typed TypeScript client for the Peerfold public API — browser/Node/edge, zero runtime deps. Types derive from the @peerfold/api-spec zod schemas so they can never drift from the OpenAPI contract.
Maintainers
Readme
@peerfold/api-client
Typed TypeScript client for the Peerfold public API (E14). Works in the
browser, Node, and edge runtimes; zero runtime dependencies (a thin wrapper
over fetch).
npm install @peerfold/api-clientimport { PeerfoldClient } from "@peerfold/api-client";
const client = new PeerfoldClient({
baseUrl: "https://acme.site.hublms.com",
learnerToken: myLearnerJwt,
});
const catalog = await client.catalog.list({ limit: 25 });A note on naming (1.0.0). The classes are
PeerfoldClient/PeerfoldError, the factory iscreatePeerfold, the wire headers arePeerfold-Version/X-Peerfold-*and the browser storage keys arepeerfold.*. 0.1.x shipped the oldHubLMS-prefixed names; 0.2.0 renamed them and the API kept answering the old wire spellings so a 0.1.1 page was not broken mid-flight. 1.0.0 closes that window: the API no longer emitsHubLMS-Versionand no longer readsX-HubLMS-Publishable-Key. Pin^1.0.0.
| 0.1.x wire name | 1.0 name | Direction |
|---|---|---|
| HubLMS-Version | Peerfold-Version | response (and the request-side version pin) |
| X-HubLMS-Publishable-Key | X-Peerfold-Publishable-Key | request |
Spec-first types (drift-impossible)
Every request/response type is z.infer<> of the exact zod schema
@peerfold/api-spec uses to validate the live API — the same schemas the
spec:check drift gate keeps welded to openapi.json. There is no
hand-written mirror of the shapes in this package. The trick (src/types.ts):
import type { z } from "zod";
type Registry = typeof import("@peerfold/api-spec/schemas").schemaRegistry;
export type Member = z.infer<Registry["Member"]>;typeof import(...) and import type are fully erased at compile time, so no
value import of zod or api-spec survives into the emitted JS — the runtime keeps
its zero-dependency promise while the types stay pinned to the OpenAPI contract.
If a field changes in the spec, this type changes automatically and stale call
sites fail to typecheck. (@peerfold/api-spec and zod are therefore
devDependencies — compile-time only.)
Install & construct
import { PeerfoldClient } from "@peerfold/api-client";
// Browser / learner plane — configured with a short-lived learner JWT.
const client = new PeerfoldClient({
baseUrl: "https://acme.site.hublms.com",
learnerToken: myLearnerJwt, // string, or a () => string | Promise<string> provider
});
// Server / admin plane — configured with a secret key (NEVER ship to a browser).
const admin = new PeerfoldClient({
baseUrl: "https://app.hublms.com",
secretKey: process.env.PEERFOLD_SK, // sk_live_… / sk_test_…
});
// Commerce plane — configured with a PUBLISHABLE key, which is safe in a
// browser: it is designed to sit in page source. This is the plane that works
// with nobody signed in.
const shop = new PeerfoldClient({
baseUrl: "https://acme.site.hublms.com",
publishableKey: "pk_live_…",
});The two browser credentials can live on one client, and several endpoints answer differently depending on which arrive — a bearer alongside a publishable key is answered on the learner plane, expired tokens and all. That precedence is the API's, and the client sends what it has rather than guessing.
fetch is injectable (fetch: myFetch) for edge runtimes or testing. Pin an API
version with version: "2026-07-01" (sent as the Peerfold-Version header).
Learner plane
const me = await client.me.get();
await client.me.update({ name: "Lee" });
const page = await client.catalog.list({ limit: 25 });
for await (const course of client.catalog.iterate()) { /* every page */ }
const detail = await client.courses.get("intro-to-widgets");
const enrollment = await client.enrollments.create({ course_slug: "intro-to-widgets" });
// Idempotency-Key is REQUIRED by the API; the client auto-generates one
// (crypto.randomUUID) unless you supply your own — so retries are always safe.
await client.progress.record(enrollment.id, {
event_id: "evt-123",
type: "lesson_completed",
lesson_id: "lesson-1",
});
const quiz = await client.quizzes.submit(enrollment.id, {
lesson_id: "lesson-2", block_id: "q1", answers: { "question-1": ["a"] },
});
const exam = await client.exams.submit(enrollment.id, {
lesson_id: "lesson-9", answers: { "question-1": ["a"] },
});
for await (const cert of client.certificates.iterate()) { /* … */ }
const verified = await client.certificates.verify("SER-1234"); // public, no auth
// Certificate sharing — the holder's own, and nobody else's. Enabling is
// idempotent; disabling REVOKES (a re-mint produces a different slug).
const { share_url } = await client.certificates.enableShare(cert.id);
await client.certificates.disableShare(cert.id);Each certificate row already carries share_url (null until minted) and
linkedin_add_url — the prefilled Add to profile link, composed server-side,
or null when the workspace has the placement off, is not entitled to it, or the
credential is revoked. Put it in an href; do not rebuild it.
The immersive layout, decided once
A course page is laid out one of two ways, and the rule is exported rather than described so three front ends cannot each hold a different copy of it:
import { isImmersiveCourse, isImmersiveStage } from "@peerfold/api-client";
const course = await client.courses.get("fire-safety");
if (isImmersiveCourse(course)) {
// no course outline beside the lesson; the content takes the full width
}A course is immersive when its layout is "immersive" (the author's toggle,
read from the live course row, so it needs no republish) or when
lesson_count is 1, which holds whatever the toggle says. Both planes carry
both fields, so a page built with a publishable key and no learner token decides
the same way a signed-in one does.
Keep your header and your footer. In a multi-lesson immersive course the footer's previous / next is the only navigation left. Nothing else moves: locks, progress writes and completion rules are identical either way.
isImmersiveStage(course, lesson) is the stronger variant, for handing a
packaged lesson the rest of the viewport. It accepts every packaged format —
SCORM 1.2, SCORM 2004, xAPI / Tin Can and cmi5 — which is the half a hand-rolled
check gets wrong: kind is "scorm" for all four, and a check that tested only
the SCORM formats once played an xAPI course boxed while its SCORM twin went
full width. It answers false for a package that is missing or still importing,
because a screen-tall band around one sentence of explanatory copy is worse than
the ordinary stage.
Commerce plane (publishable key)
The endpoints a merchant's own page calls before anybody is signed in. A visitor who has never signed in can fill a cart and pay — which is what makes the guest checkout and its claim-link path reachable at all.
// The cart token is returned ONCE. It is a capability over one cart: it names no
// member and reaches no other cart. Persist it yourself (localStorage on your
// own origin — a Cloud cookie would be third-party there).
const { cart_token, cart } = await shop.cart.create({ course_id: courseId });
shop.setCartToken(cart_token);
await shop.cart.add(otherCourseId);
await shop.cart.remove(courseId);
const priced = await shop.cart.get(); // every line re-priced from the live row
const { checkout_url, order_id } = await shop.cart.checkout({ return_url });
// Send the buyer to `checkout_url`, then poll — `?status=success` in your own
// return URL is not proof of payment.
const { status, claim_email_masked } = await shop.orders.status(order_id);
const { data: tiers } = await shop.memberships.listTiers();
const join = await shop.memberships.join(tiers[0].id, { return_url });
const { data: programs } = await shop.programs.list();
const program = await shop.programs.get("compliance-track");
const buy = await shop.programs.checkout(program.id, { return_url });Three things this plane guarantees, and they are the reason it is safe in a browser:
- No request carries an amount and there is no field to put one in. Every price is read from the live row at mint time, server-side.
orders.statusis silent in every direction. An order that does not exist, belongs to another workspace, or was never paid all answerpending, indistinguishably — so a long run ofpendingmeans "not yet", never "wrong id", and it never throws a 404 you could read as an answer.programssends whatever is configured. With a learner token as well as the key, the payload carries that learner'sprogressand acompletedflag per course; with the key alone it carries neither.
Admin / server plane
// Mint a learner token for a browser session (PRD Flow 3).
const { access_token } = await admin.admin.auth.mintLearnerToken({ email: "[email protected]" });
for await (const learner of admin.admin.learners.iterate({ status: "active" })) { /* … */ }
const learner = await admin.admin.learners.create({ email: "[email protected]" });
await admin.admin.learners.enroll(learner.id, { course_slug: "intro-to-widgets" });
const reports = await admin.admin.reports.courses();
const { secret } = await admin.admin.webhooks.create({
url: "https://api.example.com/hooks",
events: ["certificate.issued", "course.published"],
});Errors, rate limits, request ids
Every non-2xx response throws a PeerfoldError carrying the parsed RFC 7807
problem+json body:
import { PeerfoldError, getResponseMeta } from "@peerfold/api-client";
try {
await client.progress.record(id, event);
} catch (err) {
if (PeerfoldError.is(err)) {
err.status; // 429
err.code; // "rate_limited" (stable machine-readable code)
err.requestId; // echoed X-Request-Id
err.rateLimit; // { limit, remaining, reset }
err.retryable; // true for 429 / 5xx
}
}
// Rate-limit / request-id metadata is also surfaced on successful results:
const page = await client.catalog.list();
getResponseMeta(page)?.rateLimit.remaining;
client.lastResponseMeta?.apiVersion;Conventions baked in
- Auth per plane — learner methods send the learner JWT; admin methods send the secret key; calling a plane whose credential is missing throws before any network I/O. Public certificate verification needs no credential.
- Cursor pagination — every list has
.list({ cursor, limit })and a.iterate()async iterator that walks all pages. - Idempotency — auto
Idempotency-Keyonprogress.record(override via{ idempotencyKey }). - Edge-safe — only web-standard globals (
fetch,Headers,crypto,AbortSignal); no Node-only APIs in the default path.
