Doing things in bulk with the API
When you have a whole spreadsheet of devices or people to onboard — or a cart of laptops to check out at once — the single-record endpoints work, but a call per row is slow and awkward.
The bulk endpoints take a whole batch in one request: create or update many assets or people, or check many devices out or in. This is the API equivalent of a CSV import, and it's built to be safe to script against.
Examples use curl; set KEY to your API key and PANEL to your panel domain first:
export KEY="k12_live_xxxxxxxxxxxx_yyyy..."
export PANEL="https://<your-panel-domain>"
The permissions you need
A bulk call needs the same permission as the single-record version of the same operation: assets:write for /assets/bulk, people:write for /people/bulk, and checkouts:write for /checkouts/bulk. There's no separate "bulk" permission.
The shape of a bulk request
Every bulk endpoint takes the same envelope: an op (what to do) and an items array (the batch). Each item is exactly the body you'd send to the single-record endpoint.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"op": "create",
"items": [
{"name": "CB-101", "site": {"name": "High School"}, "serial": "5CD001"},
{"name": "CB-102", "site": {"name": "High School"}, "serial": "5CD002"}
]
}' \
"$PANEL/api/public/v1/assets/bulk"
The available operations:
| Endpoint | op values |
Each item is… |
|---|---|---|
POST /assets/bulk |
create, update |
a device create body, or {"id": …, …fields} to update |
POST /people/bulk |
create, update |
a person create body, or {"id": …, …fields} to update |
POST /checkouts/bulk |
checkout, checkin |
{"asset": …, "person": …} to check out; {"asset": …} to check in |
For checkouts, identify the asset by id, serial, or name, and the person by id or email — the same flexible lookup the rest of the API uses.
Small batches answer immediately; large ones run in the background
- A small batch (25 items or fewer) runs right away and returns
200with the finished result — the per-item outcomes are in the response. - A large batch returns
202 Acceptedwith ajob_id. The work runs in the background; you pollGET /jobs/{job_id}until it's done.
// 202 response for a large batch
{ "job_id": 8123, "status": "queued" }
# Poll until status is "completed"
curl -H "Authorization: Bearer $KEY" "$PANEL/api/public/v1/jobs/8123"
Both paths produce the same result shape, so the simplest client just always reads the job body — a small batch already comes back completed. A batch may contain at most 1,000 items; a larger one is rejected with 413.
Reading the result
A finished job reports a per-item outcome and a summary count:
{
"id": 8123,
"endpoint": "asset_create",
"status": "completed",
"dry_run": false,
"total": 3,
"succeeded": 2,
"failed": 1,
"results": [
{ "row": 0, "status": "created", "id": 4021, "warnings": [] },
{ "row": 1, "status": "created", "id": 4022, "warnings": [] },
{ "row": 2, "status": "failed",
"error": { "code": "serial_conflict", "message": "…", "candidates": [ … ] } }
]
}
Each row carries its row index (matching the order you sent), a status, the id of the affected record, and any advisory warnings. The status is created, updated, checked_out, checked_in, or replayed (see idempotency below) — or failed, with an error object.
Best-effort: one bad row doesn't sink the batch
Bulk is best-effort. Every valid item is applied; a bad one is reported as a failed row and the rest still go through. In the example above, two devices were created and only the row with a duplicate serial failed — you don't have to fix and resubmit all 1,000 rows because three were wrong.
A failed row's error is the same code and shape the single-record endpoint would return — serial_conflict / email_conflict with candidates, not_found, invalid, already_checked_out, and so on (see Handling errors). So the way your code reacts to an error is identical whether you call one record or a thousand.
Safe retries: item-level Idempotency-Keys
At scale, retries are a fact of life — a connection drops, or a batch half-succeeds. To make resubmission safe, give each create item its own idempotency_key:
{
"op": "create",
"items": [
{ "idempotency_key": "onboard-2026-cb101", "name": "CB-101", "site": {"name": "High School"} },
{ "idempotency_key": "onboard-2026-cb102", "name": "CB-102", "site": {"name": "High School"} }
]
}
If you resubmit a batch, any item whose key was already applied comes back with status: "replayed" and its original id — it is not created a second time. That means you can safely resend the entire batch (or just the failed rows) after any interruption without creating duplicates.
As with single creates, a create item without a natural unique value needs an idempotency_key: an asset with no serial, or a person with no email, requires one so a retry can't duplicate it.
Test first with dry-run
Add ?dry_run=true to validate an entire batch and see the would-be per-item results — without writing anything. It's the safest way to check a large import before committing to it:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"op": "create", "items": [ … ]}' \
"$PANEL/api/public/v1/people/bulk?dry_run=true"
Each row comes back with a would_create / would_update status (and any warnings or errors it would produce). Nothing is saved and no idempotency key is consumed.
A worked example: onboard a cart of laptops, then check them out
# 1. Create 30 devices in one call (returns 202 + a job_id since it's a large batch)
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d @new-devices.json \
"$PANEL/api/public/v1/assets/bulk"
# 2. Poll the job until it's done, and read the new ids
curl -H "Authorization: Bearer $KEY" "$PANEL/api/public/v1/jobs/8123"
# 3. Check them all out to their students in one call
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"op": "checkout",
"items": [
{"asset": {"serial": "5CD001"}, "person": {"email": "student1@school.org"}},
{"asset": {"serial": "5CD002"}, "person": {"email": "student2@school.org"}}
]
}' \
"$PANEL/api/public/v1/checkouts/bulk"