Skip to content

Errors, retries & idempotency

A production integration is defined by how it handles the unhappy path. This page covers the error envelope, which failures are safe to retry, and how to avoid duplicates.

The error envelope

Every error response — regardless of endpoint — has the same shape:

{
  "error": {
    "code": "not_found",
    "message": "Content item not found.",
    "request_id": "req_a1b2c3d4"
  }
}

Validation errors add field-level detail:

{
  "error": {
    "code": "validation_error",
    "message": "Validation failed.",
    "request_id": "req_a1b2c3d4",
    "field_errors": {
      "email": ["A user with this email already exists."]
    },
    "non_field_errors": ["Cannot assign archived content."]
  }
}

Branch on error.code, not on error.message — messages are for humans and may change. Always log request_id. It's the fastest path to a resolution when you contact support, and it's present on every response, success or failure.

Status codes

HTTP code Retry? What it means
400 bad_request No Malformed request. Fix the request.
401 unauthenticated / invalid_token No Missing or invalid token.
403 forbidden No Authenticated, but not permitted.
403 insufficient_scope No The token authenticated, but does not carry the scope this route needs (e.g. users:read). Mint a token with the missing scope — re-authenticating with the same token won't help.
404 not_found No Resource doesn't exist or isn't visible to your token.
409 conflict / duplicate / state-specific No Conflicts with current state (e.g. dropping a checked-in enrollment). Action endpoints return a state-specific code naming the blocking state — e.g. dropped, already_completed, not_outstanding, checked_in — listed in each endpoint's reference description.
410 gone No The resource existed but is permanently gone (e.g. an expired upload session).
412 precondition_failed After re-fetch Your If-Match ETag is stale. See Concurrency.
422 validation_error No The body or a query parameter failed validation. field_errors maps a field or query parameter (e.g. page_size) to its errors; non_field_errors lists every other error, each with its location. An error appears in one list only.
429 rate_limited Yes, after Retry-After Too many requests.
500 internal_error Yes, with backoff Unexpected server error. Quote the request_id.
503 service_unavailable Yes, with backoff A dependency (e.g. Mux, SCORM Cloud) is temporarily unavailable.

The rule of thumb: 4xx is your fault, 5xx is ours. Don't retry a 4xx without changing the request — 429 and a re-fetched 412 are the only exceptions.

Unknown fields

Breaking change

Request bodies used to ignore a field they did not accept: the request succeeded and the field was lost. Now every request body refuses such a field with 422 validation_error. Remove the field, or send the one the error message names.

This applies to request bodies only. Query parameters are still lenient: an unknown query parameter is ignored, the request succeeds, and the filter is not applied. See Filtering.

field_errors names each refused field and says how to fix the request:

  • A read-only field. The endpoint returns the field but does not accept it, e.g. public_id, created or cover_url. You cannot send a GET response back unchanged; send only the fields you change.
  • A known mix-up. The message names the right field or endpoint:
    • event_type_id or event_series_public_id on an event: send event_series_id, the series public_id.
    • cover_url: upload with POST /content-items/{public_id}/cover/uploads/, or set a cover from a URL with PUT /content-items/{public_id}/cover/.
    • duration or duration_minutes on an event: an event's duration comes from its timeslots; set duration on each timeslot.
    • description on PATCH /content-items/{public_id}/: that endpoint updates only name and is_hidden; send body_markdown to the type-specific endpoint.
    • is_self_assignable on a type other than a track or a course: other types are always self-assignable.
  • Any other field. The message lists the fields the endpoint accepts.

A field inside a list is keyed by its path, for example items.0.weight. The top-level message repeats the first field error.

{
  "error": {
    "code": "validation_error",
    "message": "Unknown field `colour`. Accepted fields: body_markdown, channel_id, description, duration, ...",
    "request_id": "req_a1b2c3d4",
    "field_errors": {
      "colour": ["Unknown field `colour`. Accepted fields: body_markdown, channel_id, description, duration, ..."]
    }
  }
}

What's safe to retry

Retries are only safe for idempotent operations — those that produce the same result whether you call them once or five times.

Operation Idempotent? Safe to blindly retry?
GET (any read) Yes Yes.
PUT (declarative replace, e.g. track items/sections) Yes Yes.
DELETE Yes (a second delete returns 404) Yes, tolerating 404.
PATCH (update) Effectively yes Yes, but prefer If-Match to avoid clobbering a concurrent change.
Idempotent actions (checkin, assignment complete, assignment/enrollment drop, bulk archive/unarchive) Yes, by design Yes.
POST (create) No No — a naive retry can create a duplicate. See below.

Several POST actions are deliberately idempotent and call it out in their description — for example, check-in returns 200 if the enrollment is already checked in, and bulk archive treats already-archived items as successes. When the reference says an action is idempotent, you can retry it freely.

Retrying creates without duplicating

Plain POST /…/ creates are not idempotent. To make creation safe:

  • Create-or-get endpoints. Some creates already de-duplicate. POST /assignments/ returns 201 for a new assignment and 200 for an existing one (same user + content item); POST /enrollments/ behaves the same way. For these, a retry is safe — you'll just get 200 the second time.
  • Check before you create. For endpoints without create-or-get semantics, query first (GET /users/?email=…) and only POST if nothing matches.
  • Reconcile after a timeout. If a create times out with no response, don't immediately re-POST. Re-fetch by a natural key (email, name) to see whether the first attempt actually succeeded, then create only if it didn't.

Handling 429 (rate limits)

Every response carries rate-limit headers:

Header Meaning
X-RateLimit-Limit Requests allowed per window.
X-RateLimit-Remaining Requests left in the current window.
X-RateLimit-Reset Seconds until the window resets.

On a 429, the response includes a Retry-After header (seconds). Honor it — sleep for Retry-After and then retry. For everything else, watch X-RateLimit-Remaining and slow down before you hit zero rather than after.

Handling 5xx (backoff)

For 500 and 503, retry with exponential backoff and jitter: wait ~1s, then ~2s, ~4s, ~8s, with a small random offset so a fleet of clients doesn't retry in lockstep. Cap the total attempts (3–5 is typical) and surface the request_id if you give up.

import time, random

def with_retries(do_request, attempts=5):
    for attempt in range(attempts):
        resp = do_request()
        if resp.status_code < 500 and resp.status_code != 429:
            return resp
        if resp.status_code == 429:
            delay = int(resp.headers.get("Retry-After", 1))
        else:  # 5xx
            delay = (2 ** attempt) + random.random()
        time.sleep(delay)
    return resp  # exhausted; inspect resp.json()["error"]["request_id"]

Concurrency with ETags

Resources that support optimistic concurrency return an ETag header on GET. Send it back as If-Match on your PATCH:

  1. GET /tracks/{public_id}/ → note the ETag response header.
  2. PATCH /tracks/{public_id}/ with If-Match: "<etag>".
  3. If someone else modified the resource in between, you get 412 precondition_failed. Re-fetch, re-apply your change, and retry. Never loop on a 412 without re-fetching — the ETag won't change on its own.

This is how you avoid the lost-update problem when two integrations (or an integration and a human in the UI) edit the same resource. See Conventions → ETags and concurrency.

Asynchronous failures

Some work happens after the API responds — bulk operations, uploads, recording transcoding. These don't fail in the original HTTP response; they fail in the status you poll for later (status: "failed" or "errored"). Always inspect the terminal status and any error_message / message field, not just the 202/201 that kicked the job off. See Bulk operations & async jobs.