Handling k12panel API errors
Every error response uses the same shape, so your program can handle them consistently.
Every error response uses the same shape, so your program can handle them consistently:
{ "error": "not_found", "message": "No person matches email 'nobody@school.org'." }
Check the HTTP status code and the error field.
Common responses
| Status | error |
Meaning | What to do |
|---|---|---|---|
| 401 | authentication_failed |
Missing, invalid, revoked, or expired key | Check the Authorization header; rotate the key if needed |
| 403 | insufficient_scope |
The key lacks the required permission | Grant the permission (or use a key that has it) |
| 404 | — | The record doesn't exist in your organization | Check the id; you can only access your own org's data |
| 409 | already_checked_out |
The asset is already checked out to someone | Check it in first, or leave it as-is |
| 409 | ambiguous_reference |
A lookup matched more than one record | Retry with an exact id (see below) |
| 409 | serial_conflict |
A create/edit would reuse a serial already in your org | Use the existing device returned in candidates |
| 409 | email_conflict |
A person create/edit would reuse an email already in your org | Use the existing person returned in candidates |
| 409 | person_sync_blocked |
Can't delete a person synced from a cloud service | Remove them in that service first |
| 409 | person_has_checkouts |
Can't delete a person with assets still checked out | Check their devices in first |
| 422 | group_is_dynamic |
Can't hand-edit membership of a rule-managed group | Change the attribute the group's rule matches on |
| 413 | batch_too_large |
A bulk request exceeded the 1,000-item limit | Split it into smaller batches |
| 422 | not_found |
A referenced record (person/site) wasn't found | Check the email/name you sent |
| 400 | unknown_include |
An unrecognised block name in include= |
Use health, security, or inventory |
| 400 | invalid_datetime |
A date filter couldn't be read | Send 2026-07-18 or 2026-07-18T14:30:00Z |
| 400 | invalid_buckets |
buckets= wasn't ascending, positive day thresholds |
Send e.g. buckets=1,7,30 |
| 400 | expand_requires_name |
expand=assets on the software catalog without name |
Add name= to pick a title first |
| 422 | invalid |
The request body failed validation | See the details field |
| 429 | — | Too many requests | Wait a moment and retry |
The "more than one match" case
Because emails, serials, and names aren't always unique, a lookup used in an action (like checkout) can match more than one record. Instead of guessing, the API returns the list of candidates so you can pick the right one and retry with its id:
{
"error": "ambiguous_reference",
"message": "3 people match email 'jsmith@school.org'. Retry with an id.",
"candidates": [
{ "id": 3087, "first_name": "John", "last_name": "Smith", "org_unit": "HS / Staff", "groups": ["Teachers"] },
{ "id": 4192, "first_name": "Jane", "last_name": "Smith", "org_unit": "MS / Staff", "groups": ["Teachers"] },
{ "id": 5310, "first_name": "Jamie", "last_name": "Smith", "org_unit": "HS / Students", "groups": ["Grade 11"] }
]
}
The easy case (a unique email) is a single call. The ambiguous case gives you everything you need to resolve it — including enough detail to show a technician a "which one?" prompt. Retry the action with the exact id:
{ "person": { "id": 3087 } }
Rate limits
Each key can make a reasonable number of requests per minute. If you exceed it you'll get a 429 response — pace your requests and retry after a moment. Well-behaved tools won't hit this in normal use.