Skip to content

Error Handling

Work top-down: run the debug checklist first, then jump to your status code.

The Debug Checklist

Ninety percent of integration failures are one of these. Check in order:

  1. Token is present and fresh — Authorization: Bearer eyJ… (capital B, one space). Decode it: echo $ACCESS_TOKEN | cut -d. -f2 | base64 -d | jq '{scope, exp, client_id}' — is exp (epoch seconds) in the future? Does scope contain what the endpoint needs?
  2. x-api-key header present — on every request, including /ping.
  3. Right environment, all three places — sandbox token endpoint + sandbox base URL + sandbox credentials. Any cross-wiring fails.
  4. Correct base URL — https://sandbox.lms.api.fsri.org/api/partner/v1 (no trailing slash before the endpoint path).
  5. Content-Type matches the method — application/json for POST, application/merge-patch+json for PATCH (plain JSON PATCH is rejected).
  6. X-User-ID on user-scoped endpoints — content, refresh-url, complete, progress, transcripts, PDFs. Must be a UUID you registered.
  7. Body is valid JSON — trailing commas and smart quotes from copy-paste are classics.

Quick isolation test — proves connectivity + API key with no other moving parts:

curl -s -w "\nHTTP %{http_code}\n" -H "x-api-key: $API_KEY" "$BASE_URL/ping"

200 healthy here + failures elsewhere = your problem is the JWT or the request itself, not connectivity.

Reading Error Responses

Where an error comes from tells you what kind of problem you have:

Response body looks like Origin Problem class
{"@type": "hydra:Error", "hydra:description": …} The API application Request semantics (your input, resource state)
{"@type": "ConstraintViolationList", "violations": […]} The API application Field-level input validation
{"error": "unauthorized", "message": …, "requestId": …} Auth layer JWT missing, malformed, or invalid
{"error": "invalid_api_key", "message": …, "requestId": …} Auth layer x-api-key missing, wrong, or revoked
{"error": "invalid_client"} etc. Token endpoint OAuth2 credentials/request

Errors are always JSON

Every error from /api/partner/v1 is JSON, whether or not you send an Accept header. You never need to set one to get a parseable error, and an API route will never hand you an HTML page.

A hydra:Error response carries the full field set:

{
  "@context": "/api/contexts/Error",
  "@type": "hydra:Error",
  "hydra:title": "Missing User Identifier",
  "hydra:description": "X-User-ID header is required.",
  "status": 400,
  "detail": "Bad Request",
  "trace_id": "6f1c2a9b4e8d7c30",
  "requestId": "6f1c2a9b4e8d7c30",
  "timestamp": "2026-08-14T17:43:52Z"
}

Branch on hydra:title; read hydra:description for the specific problem; quote trace_id to support. status always matches the HTTP status, and detail is the HTTP reason phrase. The titles worth handling:

hydra:title Status What to fix
Missing User Identifier 400 Send the X-User-ID header
Invalid User Identifier 400 The header is not a valid UUID; send it exactly as registration returned it
User Not Found 404 That UUID is not one of your registered learners
Not Found 404 The resource itself does not exist, or is not published
Forbidden 403 The learner is deactivated
Invalid Request 400 Read hydra:description; it names the offending field or parameter
Validation Failed 422 Read violations[]; fix each propertyPath per its message
Conflict 409 The action does not apply to this resource, e.g. calling /complete on interactive content that reports its own completion
Unsupported Media Type 415 Use application/merge-patch+json on PATCH
Method Not Allowed 405 Check the verb against the endpoint reference

Some errors come from the gateway, not the API

These three are answered by the API Gateway before the request reaches the application:

  • 401 — missing, malformed or invalid JWT
  • 403 — missing or unknown x-api-key
  • 429 — rate limit exceeded

They do not carry hydra:title, trace_id or the other fields above. Everything else does, so an error with no trace_id came from the gateway. That is the quickest way to tell where a failure happened, and it changes what to check: a gateway rejection means your credentials or your quota, never your request body.

The three X-User-ID cases are deliberately distinct because the fixes differ: a header you forgot, a header you malformed, and a learner you never registered.

Auth-layer errors share a common JSON shape — error (a short machine code), message (a human description), and requestId:

{ "error": "unauthorized", "message": "Invalid or missing authentication token", "requestId": "a74510e4-a4d7-4d3d-b98b-777a848c921d" }

Keep the correlation ID for support

Every partner API error carries a correlation ID, under two names: trace_id and requestId. They always hold the same value; two names exist so that both the API specification and the shape published here stay accurate. Read whichever you prefer.

It doesn't change what you fix, but quote it verbatim when you contact FSRI — it lets support find your exact request in the logs.

The value is the API Gateway request id, and it matches the x-amzn-requestid response header on the same call. Sending your own X-Request-Id header does not change it: the gateway sets its own before the application sees the request. Correlate on the header we return rather than on a value you generate.

Error Reference by Status Code

400 Bad Request

The request itself is wrong — fix it; never retry unchanged.

Likely cause Check Fix
Malformed JSON Does the body parse? Repair JSON (watch trailing commas)
Missing X-User-ID on a user-scoped endpoint Error text names the header Send the learner's UUID
Malformed UUID in X-User-ID 36-char UUID format? Send the UUID exactly as registration returned it
Invalid pagination value Is page/itemsPerPage a positive integer? Send positive integers (itemsPerPage ≤ 100; larger is clamped, not rejected)
Unknown query parameter Did you misspell a filter? Only documented parameters are accepted; check spelling — a bad name is rejected, never ignored

Field-level validation is not a 400 — see 422.

401 Unauthorized

JWT missing, expired, malformed, invalid, or from the wrong environment.

Likely cause Check Fix
Token expired Decode: is exp past? Mint a new token; implement proactive refresh
Wrong environment's token Decoded iss — which pool? Sandbox tokens for sandbox, production for production
Header malformed Exactly Authorization: Bearer {token}? Fix casing/spacing; don't quote the token
Not a token at all Whole three-part JWT pasted? Re-extract access_token from the token response
Token altered or signature invalid Token transported/stored without modification? Mint a fresh token; never edit the JWT
Token lacks the operation's scope Decoded scope claim covers the operation? Mint with partner-api/read partner-api/write

Handling rule: refresh the token and retry once. If 401 persists, stop — it's configuration, not expiry.

403 Forbidden

Two distinct causes, distinguished by the body:

Body Cause Fix
{"error": "invalid_api_key", …} x-api-key missing, wrong, or disabled Send the key on every request; contact FSRI if it persists
Error re: user status The X-User-ID user is deactivated Reactivate via PATCH {"status": "active"} if intended

404 Not Found

The resource doesn't exist for you. Partner isolation returns 404 (never 403) for other partners' resources — "exists but not yours" and "doesn't exist" are deliberately indistinguishable.

Likely cause Fix
Stale/hardcoded ID Fresh IDs from list endpoints — sandbox refreshes can change them
UUID not yours Use only UUIDs from your own /users/register calls
Module not in a published course Only published content is reachable
Path typo /course-progresses (plural, hyphenated); /transcripts/pdf (singular) for the full-transcript PDF

409 Conflict

POST /modules/{id}/complete was called on an interactive module. It reports its own completion — remove the "Mark as Complete" control for content_type: "interactive".

422 Unprocessable Content

One or more fields failed validation. The body names every offending field at once, so you can surface them all rather than fixing one per round trip:

{
  "@context": "/api/contexts/ConstraintViolationList",
  "@type": "ConstraintViolationList",
  "hydra:title": "Validation Failed",
  "hydra:description": "email: Email is not a valid address.",
  "status": 422,
  "detail": "Unprocessable Content",
  "trace_id": "6f1c2a9b4e8d7c30",
  "requestId": "6f1c2a9b4e8d7c30",
  "timestamp": "2026-09-14T17:43:52Z",
  "violations": [
    { "propertyPath": "email", "message": "Email is not a valid address.", "code": "bd79c0ab-…" }
  ]
}

Read violations[] and fix each propertyPath per its message. code is the constraint identifier, and is null for a violation with no constraint behind it — do not branch on it.

Likely cause Fix
Missing required field on POST /users/register Send email, firstName and lastName
Malformed email Send a valid address; it is lower-cased server-side
Unknown or inactive organizationId Use an id from GET /organizations; it appears as propertyPath: "organizationId"
Bad value on PATCH /users/{uuid} e.g. status outside active/inactive, countryCode not ISO 3166-1 alpha-2

Every validated route reports failures this way

Including POST /users/register, which until 2026-09-22 answered 400 and split its details between violations[] and a second errors[] array. One violations[] array, one propertyPath key, one status — see the changelog.

429 Too Many Requests

You exceeded your rate or daily quota.

Handling: exponential backoff with jitter — 1s, 2s, 4s, 8s… cap ~30s, give up after ~5 tries — applied globally: one 429 means slow everything down, not just the failed call.

Prevention: spread scheduled syncs off the top of the hour; use updatedAt[after] delta syncs instead of full refetches; cache the catalog. FSRI provides your specific limits with your credentials.

5xx Server Errors

Something failed on FSRI's side. Retry with the same backoff as 429. If sustained for more than a few minutes, capture timestamps and report it — and check whether /ping still returns healthy, which distinguishes "API down" from "one endpoint struggling."

Retry Rules Summary

Safe to retry Never retry unchanged
429, 5xx, network timeouts 400, 403, 404, 409, 422
401 — once, after a token refresh

The mutating endpoints are retry-friendly by design: registration is idempotent per email, /complete is an idempotent no-op on repeat, and merge-PATCH with the same body converges. A timed-out write can be retried without double effect.

Common Integration Mistakes

  • Mixing up the two credential systems — Client ID/Secret belongs to the token endpoint; the API key belongs in x-api-key. Swapping them = invalid_client from the token endpoint, or a 403 with {"error": "invalid_api_key"} from the API.
  • Treating /ping as proof of auth — it validates only your API key. A green /ping with failing /courses means your token is the problem.
  • PATCH with application/json — profile updates require merge-patch. The symptom is a 4xx on a request that "looks right."
  • Persisting content URLs — they expire in ~60 minutes. Symptom: content plays in testing, breaks for real users later.
  • Minting a token per request — works in testing, rate-limits under load. Cache for the hour.
  • Hardcoding sandbox IDs — sandbox content re-syncs monthly. Discover IDs from list endpoints at runtime.
  • Retrying non-retryable errors — 400/403/404/409 will fail identically forever.
  • Calling /complete for interactive modules — that's what the 409 is telling you.
  • Assuming one field-name convention — resources are camelCase; the two content endpoints are snake_case. Match each endpoint's example.

When You Contact Support

Include: environment, timestamp (with timezone), endpoint + method, the requestId from the response body if present, full request minus credentials, response status + body, your Client ID (never the secret or API key), and expected vs. actual. Send this to your FSRI integration contact.