Skip to content
  • There are no suggestions because the search field is empty.

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 200 with the finished result — the per-item outcomes are in the response.
  • A large batch returns 202 Accepted with a job_id. The work runs in the background; you poll GET /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"