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) |
{"error": "validation_failed", "violations": […]} |
The API application | Field-level input validation |
{"message": "Unauthorized"} or similar terse JSON |
API gateway | JWT missing/invalid |
Empty body with 403 |
API gateway | API key missing/invalid |
{"error": "invalid_client"} etc. |
Token endpoint | OAuth2 credentials/request |
Error Reference by Status Code¶
400 Bad Request¶
The request itself is wrong — fix it; never retry unchanged.
| Likely cause | Check | Fix |
|---|---|---|
| Validation failure | Body has violations[]? |
Fix each propertyPath per its message |
| 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 |
Example validation body:
{
"error": "validation_failed",
"violations": [
{ "propertyPath": "email", "message": "This value is not a valid email address." }
]
}
401 Unauthorized¶
JWT missing, expired, malformed, 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 |
Handling rule: refresh the token and retry once. If 401 persists, stop — it's configuration, not expiry.
403 Forbidden¶
Three distinct causes, distinguished by the body:
| Body | Cause | Fix |
|---|---|---|
| Empty | x-api-key missing, wrong, or disabled |
Send the key on every request; contact FSRI if it persists |
| Error mentioning scope/permission | Token lacks the needed scope (e.g., write op with a read-only token) | Mint with partner-api/read partner-api/write |
| 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); /transcript/pdf (singular) for the full-transcript PDF |
409 Conflict¶
POST /modules/{id}/complete was called on a SCORM module. SCORM reports its own completion — remove the "Mark as Complete" control for content_type: "scorm".
429 Too Many Requests¶
You exceeded your usage plan's 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. Your plan's specific limits are in the Partner Area.
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 |
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_clientor empty-body403. - 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 SCORM — 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, full request minus credentials, response status + body, your Client ID (never the secret or API key), and expected vs. actual. Contracted partners: see Support & Escalation for the escalation path.