Skip to content

PHP Example

A complete, copy-paste-ready API client for PHP 8.1+ using Guzzle (composer require guzzlehttp/guzzle).

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.

<?php

/**
 * FSRI Partner API — example PHP client (Guzzle).
 *
 * Copy-paste starting point for a server-side integration. Handles the full
 * auth model for you:
 *   - OAuth2 client_credentials token mint + in-memory cache + proactive refresh
 *   - both required headers on every call (Authorization: Bearer … and x-api-key)
 *   - one automatic retry after a 401 (token refreshed first)
 *
 * Requires: PHP 8.1+, guzzlehttp/guzzle ^7.0  (composer require guzzlehttp/guzzle)
 *
 * Credentials come from your secret store — never hardcode them. See usage
 * examples at the bottom of this file.
 */

declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;

class FsriPartnerClient
{
    private Client $http;
    private ?string $accessToken = null;
    private int $tokenExpiresAt = 0;

    public function __construct(
        private readonly string $baseUrl,       // e.g. https://sandbox.lms.api.fsri.org/api/partner/v1
        private readonly string $tokenUrl,      // your environment's OAuth2 token endpoint
        private readonly string $clientId,
        private readonly string $clientSecret,
        private readonly string $apiKey,        // sent as x-api-key on every request
        private readonly string $scopes = 'partner-api/read partner-api/write',
    ) {
        $this->http = new Client(['timeout' => 30]);
    }

    // ---------------------------------------------------------------- auth

    /**
     * Returns a valid access token, minting a new one if the cached token is
     * missing or within 5 minutes of expiry. You normally never call this
     * yourself — request() does.
     */
    public function authenticate(): string
    {
        if ($this->accessToken !== null && time() < $this->tokenExpiresAt - 300) {
            return $this->accessToken;
        }

        $response = $this->http->post($this->tokenUrl, [
            'headers' => [
                // HTTP Basic auth: base64(clientId:clientSecret)
                'Authorization' => 'Basic ' . base64_encode("{$this->clientId}:{$this->clientSecret}"),
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
            'form_params' => [
                'grant_type' => 'client_credentials',
                'scope' => $this->scopes,
            ],
        ]);

        $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);

        $this->accessToken = $body['access_token'];
        $this->tokenExpiresAt = time() + (int) $body['expires_in'];

        return $this->accessToken;
    }

    /**
     * Core request helper: adds both auth headers, retries once on 401 after
     * refreshing the token, decodes JSON responses.
     *
     * @param array<string,mixed> $options extra Guzzle options (json, headers, query…)
     */
    public function request(string $method, string $path, array $options = [], bool $isRetry = false): mixed
    {
        $options['headers'] = array_merge($options['headers'] ?? [], [
            'Authorization' => 'Bearer ' . $this->authenticate(),
            'x-api-key' => $this->apiKey,
        ]);

        try {
            $response = $this->http->request($method, $this->baseUrl . $path, $options);
        } catch (BadResponseException $e) {
            $status = $e->getResponse()->getStatusCode();

            // Token may have expired mid-flight: refresh once and retry.
            if ($status === 401 && !$isRetry) {
                $this->accessToken = null;
                return $this->request($method, $path, $options, isRetry: true);
            }

            throw $e; // 4xx/5xx — let the caller decide (see retry guidance in the guide §7)
        }

        $contentType = $response->getHeaderLine('Content-Type');
        if (str_contains($contentType, 'pdf')) {
            return (string) $response->getBody(); // raw PDF bytes
        }
        if ($response->getStatusCode() === 204) {
            return null; // e.g. DELETE /users/{uuid}
        }

        return json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
    }

    // ------------------------------------------------------------- users

    /**
     * Register a learner; returns their profile including the UUID you must
     * persist. Idempotent per email — safe to retry after a timeout.
     *
     * @param array{email:string, firstName?:string, lastName?:string, organizationId?:int} $user
     */
    public function registerUser(array $user): array
    {
        return $this->request('POST', '/users/register', ['json' => $user]);
    }

    public function getUser(string $uuid): array
    {
        return $this->request('GET', "/users/{$uuid}");
    }

    /** PATCH requires merge-patch content type; send only fields to change. */
    public function updateUser(string $uuid, array $fields): array
    {
        return $this->request('PATCH', "/users/{$uuid}", [
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
            'body' => json_encode($fields, JSON_THROW_ON_ERROR),
        ]);
    }

    /** Reversible soft-deactivation. Reactivate with status "active". */
    public function setUserStatus(string $uuid, string $status): array
    {
        return $this->updateUser($uuid, ['status' => $status]);
    }

    /** GDPR erasure — permanent. Returns null (HTTP 204). */
    public function deleteUser(string $uuid): mixed
    {
        return $this->request('DELETE', "/users/{$uuid}");
    }

    // ------------------------------------------------------------ catalog

    /** @param array<string,mixed> $query e.g. ['page' => 1, 'itemsPerPage' => 25, 'updatedAt[after]' => '2026-07-01T00:00:00Z'] */
    public function listOrganizations(array $query = []): array
    {
        return $this->request('GET', '/organizations', ['query' => $query]);
    }

    public function listCategories(array $query = []): array
    {
        return $this->request('GET', '/categories', ['query' => $query]);
    }

    public function listCourses(array $query = []): array
    {
        return $this->request('GET', '/courses', ['query' => $query]);
    }

    public function getCourse(int $id): array
    {
        return $this->request('GET', "/courses/{$id}");
    }

    public function listModules(array $query = []): array
    {
        return $this->request('GET', '/modules', ['query' => $query]);
    }

    public function getModule(int $id): array
    {
        return $this->request('GET', "/modules/{$id}");
    }

    // --------------------------------------------------- content delivery

    /**
     * Signed, time-limited content URL for a learner. Embed content_url in an
     * iframe/player; watch expires_at and use refreshContentUrl() for long
     * playback. Side effect: the learner's progress record is auto-created.
     */
    public function getModuleContent(int $moduleId, string $userUuid): array
    {
        return $this->request('GET', "/modules/{$moduleId}/content", [
            'headers' => ['X-User-ID' => $userUuid],
        ]);
    }

    public function refreshContentUrl(int $moduleId, string $userUuid): array
    {
        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.
     */
    public function markModuleComplete(int $moduleId, string $userUuid): mixed
    {
        return $this->request('POST', "/modules/{$moduleId}/complete", [
            'headers' => ['X-User-ID' => $userUuid],
        ]);
    }

    // -------------------------------------------- progress & transcripts

    public function listCourseProgresses(string $userUuid, array $query = []): array
    {
        return $this->request('GET', '/course-progresses', [
            'headers' => ['X-User-ID' => $userUuid],
            'query' => $query,
        ]);
    }

    public function listTranscripts(string $userUuid, array $query = []): array
    {
        return $this->request('GET', '/transcripts', [
            'headers' => ['X-User-ID' => $userUuid],
            'query' => $query,
        ]);
    }

    /** Full transcript PDF — returns raw PDF bytes. */
    public function getTranscriptPdf(string $userUuid): string
    {
        return $this->request('GET', '/transcript/pdf', [
            'headers' => ['X-User-ID' => $userUuid],
        ]);
    }

    /** Single course certificate PDF — returns raw PDF bytes. */
    public function getCertificatePdf(int $transcriptId, string $userUuid): string
    {
        return $this->request('GET', "/transcripts/{$transcriptId}/certificate/pdf", [
            'headers' => ['X-User-ID' => $userUuid],
        ]);
    }
}

// ---------------------------------------------------------------------------
// Usage examples — each scenario from the Integration Guide's canonical flow.
// ---------------------------------------------------------------------------

/*

// Setup — pull credentials from your secret store / environment:
$client = new FsriPartnerClient(
    baseUrl:      'https://sandbox.lms.api.fsri.org/api/partner/v1',
    tokenUrl:     getenv('FSRI_TOKEN_URL'),
    clientId:     getenv('FSRI_CLIENT_ID'),
    clientSecret: getenv('FSRI_CLIENT_SECRET'),
    apiKey:       getenv('FSRI_API_KEY'),
);

// --- Scenario 1: authentication (implicit — but you can verify eagerly) ---
$client->authenticate(); // throws on bad credentials; otherwise you're set

// --- Scenario 2: register a learner and persist their UUID ---
$profile = $client->registerUser([
    'email' => 'jane.doe@yourcompany.example',
    'firstName' => 'Jane',
    'lastName' => 'Doe',
]);
$uuid = $profile['uuid']; // store this on your user record — it's their FSRI identity

// --- Scenario 3: course catalog listing (with pagination) ---
$page = $client->listCourses(['page' => 1, 'itemsPerPage' => 25]);
foreach ($page['hydra:member'] as $course) {
    printf("#%d %s\n", $course['id'], $course['name']);
}
// Delta sync on later runs:
$changed = $client->listCourses(['updatedAt[after]' => '2026-07-01T00:00:00Z']);

// --- Scenario 4: launch content, keep the URL fresh, mark complete ---
$content = $client->getModuleContent(467, $uuid);
// embed $content['content_url'] in an iframe/player per $content['content_type'];
// during long playback, refresh before $content['expires_at']:
$fresh = $client->refreshContentUrl(467, $uuid);
// when the learner clicks "Mark as Complete" (video/document/resource only):
$client->markModuleComplete(467, $uuid);

// --- Scenario 5: progress tracking ---
$progress = $client->listCourseProgresses($uuid);
foreach ($progress['hydra:member'] as $record) {
    printf("course %d: %s (%d%%)\n", $record['courseId'], $record['status'], $record['completionPercent']);
}

// --- Scenario 6: transcript & certificate generation ---
$transcripts = $client->listTranscripts($uuid);
foreach ($transcripts['hydra:member'] as $t) {
    if ($t['certificateAvailable']) {
        file_put_contents("cert-{$t['id']}.pdf", $client->getCertificatePdf($t['id'], $uuid));
    }
}
file_put_contents('transcript.pdf', $client->getTranscriptPdf($uuid));

*/