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:
- 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}'— isexp(epoch seconds) in the future? Doesscopecontain what the endpoint needs? x-api-keyheader present — on every request, including/ping.- Right environment, all three places — sandbox token endpoint + sandbox base URL + sandbox credentials. Any cross-wiring fails.
- Correct base URL —
https://sandbox.lms.api.fsri.org/api/partner/v1(no trailing slash before the endpoint path). Content-Typematches the method —application/jsonfor POST,application/merge-patch+jsonfor PATCH (plain JSON PATCH is rejected).X-User-IDon user-scoped endpoints — content, refresh-url, complete, progress, transcripts, PDFs. Must be a UUID you registered.- 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:
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_clientfrom the token endpoint, or a403with{"error": "invalid_api_key"}from the API. - Treating
/pingas proof of auth — it validates only your API key. A green/pingwith failing/coursesmeans 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/409will fail identically forever. - Calling
/completefor interactive modules — that's what the409is 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.