TrainingOS Integration Guide

TrainingOS Integration Guide

Complete guide for integrating the AllureLMS SCORM API with TrainingOS.

Table of Contents

Overview

This guide covers integrating the SCORM API with TrainingOS to enable:

  • SCORM package upload and management
  • Session creation and tracking
  • Learner progress monitoring
  • Completion and score reporting

Prerequisites

  • TrainingOS account with admin access
  • SCORM API account and API key
  • Node.js/TypeScript development environment
  • Understanding of REST APIs

Setup

1. Get API Credentials

  1. Sign in to Allure Admin Dashboard
  2. Navigate to AdminSCORM API
  3. Create a new tenant for TrainingOS (if not exists)
  4. Generate an API key with read and write scopes
  5. Copy your tenant ID and API key

2. Environment Variables

Add to your TrainingOS .env file:

SCORM_API_URL=https://scorm-api.allurelms.com
SCORM_API_KEY=scorm_your-api-key-here
SCORM_TENANT_ID=your-tenant-uuid

3. Install Dependencies

No special dependencies required - use native fetch or your preferred HTTP client.

Core Integration

TypeScript Client Class

Create a reusable client class:

// lib/scorm-api-client.ts
export class ScormAPIClient {
  private apiKey: string;
  private baseUrl: string;
  private tenantId: string;

  constructor() {
    this.apiKey = process.env.SCORM_API_KEY!;
    this.baseUrl = process.env.SCORM_API_URL!;
    this.tenantId = process.env.SCORM_TENANT_ID!;
  }

  private async request(
    path: string,
    options: RequestInit = {}
  ): Promise<Response> {
    const url = `${this.baseUrl}${path}`;
    const headers = {
      'X-API-Key': this.apiKey,
      ...options.headers,
    };

    return this.fetchWithRetry(url, { ...options, headers });
  }

  private async fetchWithRetry(
    url: string,
    options: RequestInit,
    maxRetries = 3
  ): Promise<Response> {
    for (let attempt = 0; attempt < maxRetries; attempt++) {
      try {
        const response = await fetch(url, options);

        // Don't retry client errors (4xx)
        if (response.ok || (response.status >= 400 && response.status < 500)) {
          return response;
        }

        // Retry server errors (5xx) with exponential backoff
        if (attempt < maxRetries - 1) {
          const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      } catch (error) {
        if (attempt === maxRetries - 1) throw error;
        await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
      }
    }
    throw new Error('Max retries exceeded');
  }

  async uploadPackage(file: File, uploadedBy: string) {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('tenant_id', this.tenantId);
    formData.append('uploaded_by', uploadedBy);

    const response = await this.request('/api/v1/packages', {
      method: 'POST',
      body: formData,
    });

    if (!response.ok) {
      throw new Error(`Upload failed: ${response.statusText}`);
    }

    return response.json();
  }

  async listPackages(page = 1, limit = 20) {
    const url = `/api/v1/packages?tenant_id=${this.tenantId}&page=${page}&limit=${limit}`;
    const response = await this.request(url);
    return response.json();
  }

  async getPackage(packageId: string) {
    const response = await this.request(`/api/v1/packages/${packageId}`);
    return response.json();
  }

  async launchSession(packageId: string, userId: string, sessionId?: string) {
    const response = await this.request(`/api/v1/packages/${packageId}/launch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        user_id: userId,
        session_id: sessionId || crypto.randomUUID(),
      }),
    });
    return response.json();
  }

  async getSession(sessionId: string) {
    const response = await this.request(`/api/v1/sessions/${sessionId}`);
    return response.json();
  }

  async updateSession(sessionId: string, updates: any, maxRetries = 3) {
    for (let attempt = 0; attempt < maxRetries; attempt++) {
      const session = await this.getSession(sessionId);

      const payload = {
        version: session.version,
        cmi_data: {
          ...session.cmi_data,
          ...updates.cmi_data,
        },
        ...updates,
      };

      const response = await this.request(`/api/v1/sessions/${sessionId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });

      if (response.ok) {
        return response.json();
      }

      if (response.status === 409 && attempt < maxRetries - 1) {
        console.log(`Version conflict, retrying... (${attempt + 1}/${maxRetries})`);
        continue;
      }

      throw new Error(`Update failed: ${response.status}`);
    }
    throw new Error('Max retries exceeded');
  }
}

export const scormAPI = new ScormAPIClient();

Upload Flow

// services/scorm-upload.ts
import { scormAPI } from '@/lib/scorm-api-client';

export async function uploadScormPackage(
  file: File,
  userId: string
): Promise<{ packageId: string; title: string }> {
  try {
    const pkg = await scormAPI.uploadPackage(file, userId);
    
    // Store package ID in TrainingOS database
    await savePackageToDatabase({
      scormPackageId: pkg.id,
      title: pkg.title,
      version: pkg.version,
      uploadedBy: userId,
    });

    return {
      packageId: pkg.id,
      title: pkg.title,
    };
  } catch (error) {
    console.error('SCORM upload failed:', error);
    throw new Error('Failed to upload SCORM package');
  }
}

Launch Flow

// services/scorm-launch.ts
import { scormAPI } from '@/lib/scorm-api-client';

export async function launchScormCourse(
  packageId: string,
  userId: string
): Promise<{ sessionId: string; playerUrl: string }> {
  try {
    // Check for existing incomplete session
    const existingSession = await findIncompleteSession(packageId, userId);
    
    if (existingSession) {
      return {
        sessionId: existingSession.id,
        playerUrl: `https://scorm-api.allurelms.com/player/${existingSession.id}`,
      };
    }

    // Create new session
    const launch = await scormAPI.launchSession(packageId, userId);
    
    return {
      sessionId: launch.session_id,
      playerUrl: launch.player_url,
    };
  } catch (error) {
    console.error('SCORM launch failed:', error);
    throw new Error('Failed to launch SCORM course');
  }
}

Progress Tracking

// services/scorm-tracking.ts
import { scormAPI } from '@/lib/scorm-api-client';

export async function trackScormProgress(sessionId: string) {
  const session = await scormAPI.getSession(sessionId);

  // Update TrainingOS database
  await updateCourseProgress({
    sessionId,
    completionStatus: session.completion_status,
    successStatus: session.success_status,
    score: session.score?.scaled,
    timeSpent: session.time_spent_seconds,
  });

  return {
    completed: session.completion_status === 'completed',
    passed: session.success_status === 'passed',
    score: session.score?.scaled,
    timeSpent: session.time_spent_seconds,
  };
}

// Poll for completion
export function pollScormCompletion(
  sessionId: string,
  onComplete: (result: any) => void
) {
  const interval = setInterval(async () => {
    try {
      const progress = await trackScormProgress(sessionId);
      
      if (progress.completed) {
        clearInterval(interval);
        onComplete(progress);
      }
    } catch (error) {
      console.error('Progress tracking failed:', error);
      clearInterval(interval);
    }
  }, 5000); // Poll every 5 seconds

  return () => clearInterval(interval);
}

Advanced Features

Webhook Integration

Subscribe to package processing events:

// Setup webhook
await scormAPI.request('/api/v1/webhooks', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    tenant_id: process.env.SCORM_TENANT_ID,
    url: 'https://trainingos.com/api/webhooks/scorm',
    secret: process.env.WEBHOOK_SECRET,
    event_type: 'package.processing.completed',
  }),
});

// Webhook handler in TrainingOS
export async function POST(request: Request) {
  const signature = request.headers.get('X-Allure-Signature');
  const body = await request.text();
  
  // Verify signature
  const expectedSignature = createHmac('sha256', process.env.WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');
  
  if (signature !== expectedSignature) {
    return new Response('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(body);
  
  if (event.event === 'package.processing.completed') {
    await handlePackageProcessed(event.data);
  }

  return new Response('OK');
}

Version Management

Upload new versions while keeping stable package IDs:

async function updatePackageVersion(
  existingPackageId: string,
  newFile: File,
  userId: string
) {
  const formData = new FormData();
  formData.append('file', newFile);
  formData.append('tenant_id', process.env.SCORM_TENANT_ID!);
  formData.append('uploaded_by', userId);
  formData.append('package_id', existingPackageId); // Keep same ID

  const response = await scormAPI.request('/api/v1/packages', {
    method: 'POST',
    body: formData,
  });

  return response.json();
}

Error Handling

Version Conflicts

Always handle 409 conflicts when updating sessions:

async function updateSessionWithRetry(
  sessionId: string,
  updates: any
): Promise<any> {
  try {
    return await scormAPI.updateSession(sessionId, updates);
  } catch (error) {
    if (error.message.includes('409')) {
      // Retry with fresh data
      return updateSessionWithRetry(sessionId, updates);
    }
    throw error;
  }
}

Rate Limiting

Handle 429 rate limit errors:

private async handleRateLimit(response: Response): Promise<Response> {
  if (response.status === 429) {
    const retryAfter = parseInt(
      response.headers.get('Retry-After') || '60'
    );
    console.log(`Rate limited. Waiting ${retryAfter} seconds...`);
    
    await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
    
    // Retry the request
    return this.request(response.url);
  }
  return response;
}

Best Practices

  1. Cache Package Data: Store package metadata in TrainingOS to reduce API calls
  2. Use Webhooks: Subscribe to events instead of polling when possible
  3. Handle Errors Gracefully: Implement retry logic for transient failures
  4. Monitor Quotas: Track package and storage usage
  5. Version Management: Use package versioning for updates
  6. Session Resumption: Check for existing incomplete sessions before creating new ones

Production Checklist

  • Environment variables configured
  • API client implemented with error handling
  • Version conflict handling (409 errors)
  • Retry logic for server errors (500/503)
  • Rate limit handling (429 errors)
  • Upload flow tested with real SCORM packages
  • Session creation and tracking tested
  • Concurrent user scenarios tested
  • Completion tracking implemented
  • Score reporting working
  • Webhook integration (optional but recommended)
  • Monitoring and alerting configured

Related Documentation


Last Updated: 2025-01-15
See Also: TrainingOS Integration (Original)