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