JavaScript Example¶
A complete, copy-paste-ready API client for Node 18+ — zero dependencies (uses the built-in fetch).
Handles the full auth model for you: OAuth2 token mint + cache + proactive refresh, both required headers on every call, and a single automatic retry after a 401. Usage examples for every scenario in the canonical flow are in the comment block at the bottom.
Server-side only
This client must run on your backend. Your client secret and API key can never ship to a browser or mobile app.
/**
* FSRI Partner API — example JavaScript client (Node 18+, built-in fetch).
*
* Copy-paste starting point for a server-side integration. Handles the full
* auth model for you:
* - OAuth2 client_credentials token mint + cache + proactive refresh
* - both required headers on every call (Authorization: Bearer … and x-api-key)
* - one automatic retry after a 401 (token refreshed first)
*
* ⚠️ SERVER-SIDE ONLY. Your client secret and API key must never ship to a
* browser or mobile app — run this behind your own backend.
*
* Credentials come from your secret store — never hardcode them. Usage
* examples at the bottom of this file.
*/
class FsriPartnerClient {
/**
* @param {object} config
* @param {string} config.baseUrl e.g. 'https://sandbox.lms.api.fsri.org/api/partner/v1'
* @param {string} config.tokenUrl your environment's OAuth2 token endpoint
* @param {string} config.clientId
* @param {string} config.clientSecret
* @param {string} config.apiKey sent as x-api-key on every request
* @param {string} [config.scopes]
*/
constructor({ baseUrl, tokenUrl, clientId, clientSecret, apiKey, scopes }) {
this.baseUrl = baseUrl;
this.tokenUrl = tokenUrl;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.apiKey = apiKey;
this.scopes = scopes ?? 'partner-api/read partner-api/write';
this.accessToken = null;
this.tokenExpiresAt = 0; // epoch ms
}
// ---------------------------------------------------------------- auth
/**
* Returns a valid access token, minting a new one if the cached token is
* missing or within 5 minutes of expiry. request() calls this for you.
* @returns {Promise<string>}
*/
async authenticate() {
if (this.accessToken && Date.now() < this.tokenExpiresAt - 300_000) {
return this.accessToken;
}
const basic = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
const res = await fetch(this.tokenUrl, {
method: 'POST',
headers: {
// HTTP Basic auth: base64(clientId:clientSecret)
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: this.scopes,
}),
});
if (!res.ok) {
throw new Error(`Token request failed (${res.status}): ${await res.text()}`);
}
const body = await res.json();
this.accessToken = body.access_token;
this.tokenExpiresAt = Date.now() + body.expires_in * 1000;
return this.accessToken;
}
/**
* Core request helper: adds both auth headers, retries once on 401 after a
* token refresh, decodes JSON (or returns a Buffer for PDFs, null for 204).
*
* @param {string} method
* @param {string} path path relative to baseUrl, e.g. '/courses'
* @param {object} [opts]
* @param {object} [opts.headers] extra headers (e.g. X-User-ID)
* @param {object} [opts.query] query params, e.g. { page: 1, 'updatedAt[after]': '…' }
* @param {object} [opts.json] JSON body
* @param {string} [opts.contentType] override body content type (merge-patch)
* @param {boolean} [isRetry]
*/
async request(method, path, opts = {}, isRetry = false) {
const url = new URL(this.baseUrl + path);
for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
const headers = {
Authorization: `Bearer ${await this.authenticate()}`,
'x-api-key': this.apiKey,
...(opts.json ? { 'Content-Type': opts.contentType ?? 'application/json' } : {}),
...opts.headers,
};
const res = await fetch(url, {
method,
headers,
body: opts.json ? JSON.stringify(opts.json) : undefined,
});
// Token may have expired mid-flight: refresh once and retry.
if (res.status === 401 && !isRetry) {
this.accessToken = null;
return this.request(method, path, opts, true);
}
if (!res.ok) {
const err = new Error(`${method} ${path} → ${res.status}: ${await res.text()}`);
err.status = res.status;
throw err; // 4xx/5xx — caller decides (see retry guidance in the guide §7)
}
if (res.status === 204) return null; // e.g. DELETE /users/{uuid}
const contentType = res.headers.get('content-type') ?? '';
if (contentType.includes('pdf')) return Buffer.from(await res.arrayBuffer());
return res.json();
}
// ------------------------------------------------------------- users
/**
* Register a learner; returns their profile including the UUID you must
* persist. Idempotent per email — safe to retry after a timeout.
* @param {{email: string, firstName?: string, lastName?: string, organizationId?: number}} user
*/
registerUser(user) {
return this.request('POST', '/users/register', { json: user });
}
getUser(uuid) {
return this.request('GET', `/users/${uuid}`);
}
/** PATCH requires merge-patch content type; send only fields to change. */
updateUser(uuid, fields) {
return this.request('PATCH', `/users/${uuid}`, {
json: fields,
contentType: 'application/merge-patch+json',
});
}
/** Reversible soft-deactivation ('inactive'); reactivate with 'active'. */
setUserStatus(uuid, status) {
return this.updateUser(uuid, { status });
}
/** GDPR erasure — permanent. Resolves to null (HTTP 204). */
deleteUser(uuid) {
return this.request('DELETE', `/users/${uuid}`);
}
// ------------------------------------------------------------ catalog
listOrganizations(query = {}) { return this.request('GET', '/organizations', { query }); }
listCategories(query = {}) { return this.request('GET', '/categories', { query }); }
listCourses(query = {}) { return this.request('GET', '/courses', { query }); }
getCourse(id) { return this.request('GET', `/courses/${id}`); }
listModules(query = {}) { return this.request('GET', `/modules`, { query }); }
getModule(id) { return this.request('GET', `/modules/${id}`); }
// --------------------------------------------------- content delivery
/**
* Signed, time-limited content URL for a learner. Embed content_url per
* content_type; watch expires_at and call refreshContentUrl() during long
* playback. Side effect: the learner's progress record is auto-created.
*/
getModuleContent(moduleId, userUuid) {
return this.request('GET', `/modules/${moduleId}/content`, {
headers: { 'X-User-ID': userUuid },
});
}
refreshContentUrl(moduleId, userUuid) {
return this.request('GET', `/modules/${moduleId}/refresh-url`, {
headers: { 'X-User-ID': userUuid },
});
}
/**
* Completion notification for video/document/resource modules (NOT SCORM —
* that returns 409). 202 on success; idempotent on repeat calls.
*/
markModuleComplete(moduleId, userUuid) {
return this.request('POST', `/modules/${moduleId}/complete`, {
headers: { 'X-User-ID': userUuid },
});
}
// -------------------------------------------- progress & transcripts
listCourseProgresses(userUuid, query = {}) {
return this.request('GET', '/course-progresses', {
headers: { 'X-User-ID': userUuid }, query,
});
}
listTranscripts(userUuid, query = {}) {
return this.request('GET', '/transcripts', {
headers: { 'X-User-ID': userUuid }, query,
});
}
/** Full transcript PDF — resolves to a Buffer. */
getTranscriptPdf(userUuid) {
return this.request('GET', '/transcript/pdf', {
headers: { 'X-User-ID': userUuid },
});
}
/** Single course certificate PDF — resolves to a Buffer. */
getCertificatePdf(transcriptId, userUuid) {
return this.request('GET', `/transcripts/${transcriptId}/certificate/pdf`, {
headers: { 'X-User-ID': userUuid },
});
}
}
module.exports = { FsriPartnerClient };
// ---------------------------------------------------------------------------
// Usage examples — each scenario from the Integration Guide's canonical flow.
// ---------------------------------------------------------------------------
/*
const { FsriPartnerClient } = require('./fsri-partner-client');
const fs = require('node:fs');
// Setup — pull credentials from your secret store / environment:
const client = new FsriPartnerClient({
baseUrl: 'https://sandbox.lms.api.fsri.org/api/partner/v1',
tokenUrl: process.env.FSRI_TOKEN_URL,
clientId: process.env.FSRI_CLIENT_ID,
clientSecret: process.env.FSRI_CLIENT_SECRET,
apiKey: process.env.FSRI_API_KEY,
});
async function main() {
// --- Scenario 1: authentication (implicit — but you can verify eagerly) ---
await client.authenticate(); // throws on bad credentials
// --- Scenario 2: register a learner and persist their UUID ---
const profile = await client.registerUser({
email: 'jane.doe@yourcompany.example',
firstName: 'Jane',
lastName: 'Doe',
});
const uuid = profile.uuid; // store on your user record — it's their FSRI identity
// --- Scenario 3: course catalog listing (with pagination + delta sync) ---
const page = await client.listCourses({ page: 1, itemsPerPage: 25 });
for (const course of page['hydra:member']) console.log(course.id, course.name);
const changed = await client.listCourses({ 'updatedAt[after]': '2026-07-01T00:00:00Z' });
// --- Scenario 4: launch content, keep the URL fresh, mark complete ---
const content = await client.getModuleContent(467, uuid);
// embed content.content_url per content.content_type; refresh before content.expires_at:
const fresh = await client.refreshContentUrl(467, uuid);
// when the learner clicks "Mark as Complete" (video/document/resource only):
await client.markModuleComplete(467, uuid);
// --- Scenario 5: progress tracking ---
const progress = await client.listCourseProgresses(uuid);
for (const rec of progress['hydra:member']) {
console.log(`course ${rec.courseId}: ${rec.status} (${rec.completionPercent}%)`);
}
// --- Scenario 6: transcript & certificate generation ---
const transcripts = await client.listTranscripts(uuid);
for (const t of transcripts['hydra:member']) {
if (t.certificateAvailable) {
fs.writeFileSync(`cert-${t.id}.pdf`, await client.getCertificatePdf(t.id, uuid));
}
}
fs.writeFileSync('transcript.pdf', await client.getTranscriptPdf(uuid));
}
main().catch(console.error);
*/