Skip to content

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);

*/