Skip to content

Bulk Enrol Devices via the API

Script the same bulk-enrolment flow the portal exposes. Useful for fleet-scale onboardings (1000+ devices) where the portal upload form isn't ergonomic.

Who can call these endpoints

Tenant administrators only — the same role gate as the portal. Authenticate with a bearer token issued to a tenant-admin user.

Endpoints

All paths are relative to https://dev-au-03.nnnco.io/api/v4/registry/.

Method Path Purpose
POST bulk-enrolments/ Submit a CSV
GET bulk-enrolments/{batch_id}/ Status + per-row results
GET bulk-enrolments/{batch_id}/failures/ Download the failed rows as CSV

Submit

POST /api/v4/registry/bulk-enrolments/

A multipart upload with two fields:

Field Required Description
enterprise_id yes UUID of the target enterprise. Must be in your accessible-enterprises tree.
csv_file yes UTF-8 CSV body. Max 5 MB. Format described below.

Example

curl -X POST https://dev-au-03.nnnco.io/api/v4/registry/bulk-enrolments/ \
  -H "Authorization: Bearer $TOKEN" \
  -F "enterprise_id=00000000-0000-0000-0000-000000000000" \
  -F "csv_file=@devices.csv"

CSV format

Each row enrols one device. Five columns, mixing OTAA and ABP device types in the same file is allowed. The activation mode and the meaning of each key column follow from the resolved device type (its device_type_code).

Column OTAA device type ABP device type
dev_eui required (16 hex) required (16 hex)
dev_addr blank required (8 hex)
device_type_code required required
key_1 AppKey (32 hex) NwkSKey (32 hex)
key_2 blank AppSKey (32 hex)

Example:

AABBCCDDEEFF0001,,LS-300-OTAA,AABBCCDDEEFF00112233445566778899,
AABBCCDDEEFF0002,00ABC123,LS-300-ABP,AABBCCDDEEFF00112233445566778899,DDEEFF00112233445566778899AABBCC

Keys are forwarded directly to the network server. They are never stored on this platform and cannot be retrieved after enrolment.

Cellular and NB-IoT device types

Bulk enrolment supports LoRaWAN device types in this release. Cellular and NB-IoT device types will get their own enrolment flow once that adapter ships — enrol them via the single-device API for now.

Response

202 Accepted — the upload was validated and forwarded. The response body is the batch in state=running; poll the status endpoint until is_terminal is true.

{
  "id": "11111111-2222-3333-4444-555555555555",
  "enterprise_id": "00000000-0000-0000-0000-000000000000",
  "enterprise_code": "acme.us-east",
  "state": "running",
  "is_terminal": false,
  "total_rows": 250,
  "succeeded_rows": 0,
  "failed_rows": 0,
  "submitted_at": "2026-05-28T01:23:45Z",
  "last_polled_at": null,
  "completed_at": null,
  "rows": [...],
  "row_count_truncated": false
}

The Location response header points at the status endpoint for this batch.

Errors

Status When
400 CSV body missing/malformed; unknown enterprise; duplicate or oversized DevEUI in CSV.
403 Not a tenant admin, or enterprise_id outside your accessible tree.
413 CSV exceeds 5 MB. Split into multiple uploads.
502 Network server rejected the import (transient).

Error bodies have a detail (human message) and code (machine token):

{
  "detail": "CSV exceeds 5242880 bytes — split into multiple uploads.",
  "code": "csv_too_large"
}

Get batch status

GET /api/v4/registry/bulk-enrolments/{batch_id}/

Returns the current state of the batch plus the per-row breakdown. This endpoint lazy-polls the upstream network server on each call when the batch is non-terminal — you don't need a separate poll endpoint, calling this is the poll. Repeat the call until is_terminal is true.

Polling tapers off automatically: once the batch is older than 30 minutes and still non-terminal, the server returns the last-known state without polling the network server again. Refresh manually later if needed.

Response shape

{
  "id": "11111111-2222-3333-4444-555555555555",
  "enterprise_id": "00000000-0000-0000-0000-000000000000",
  "enterprise_code": "acme.us-east",
  "state": "partial",
  "is_terminal": true,
  "total_rows": 250,
  "succeeded_rows": 248,
  "failed_rows": 2,
  "submitted_at": "2026-05-28T01:23:45Z",
  "last_polled_at": "2026-05-28T01:24:01Z",
  "completed_at": "2026-05-28T01:24:01Z",
  "rows": [
    {
      "row_index": 17,
      "device_eui": "AABBCCDDEEFF0011",
      "op_type": "OTAA",
      "result": "error",
      "error_code": "111",
      "error_message": "duplicate DevEUI",
      "created_device_id": null
    }
  ],
  "row_count_truncated": false
}

op_type is the device's activation mode (OTAA or ABP), derived from the resolved device type. error_code is an opaque token extracted from the upstream response — treat as text, not as a number; values may be numeric (e.g. "111") or symbolic depending on the upstream message format.

The rows array is capped at 500 entries to bound the response size. If row_count_truncated is true, download the failures CSV (next endpoint) for the full set.

Errors

Status When
403 Not a tenant admin.
404 Batch not found, or not in your accessible-enterprises tree.
503 Database temporarily unavailable. Retry with exponential back-off.

Download failures CSV

GET /api/v4/registry/bulk-enrolments/{batch_id}/failures/

Streams only the failed rows as CSV. Useful for the fix-and-resubmit loop: download, correct the failing rows (keys, DevEUIs, or device-type codes), and POST the corrected CSV.

Response

200 OK, Content-Type: text/csv, Content-Disposition: attachment.

row_index,op_type,device_eui,error_code,error_message
17,OTAA,AABBCCDDEEFF0011,111,duplicate DevEUI
42,ABP,AABBCCDDEEFF0042,222,invalid AppSKey length
  • Capped at the same row limit as the portal export. If the failures exceed that cap, the filename includes -partial-{N}.csv.
  • Cell values starting with =, +, -, @ are prefixed with a single quote so spreadsheet applications don't interpret them as formulas.

Putting it together

Typical scripted flow:

# 1. Submit the CSV.
BATCH=$(curl -s -X POST https://dev-au-03.nnnco.io/api/v4/registry/bulk-enrolments/ \
  -H "Authorization: Bearer $TOKEN" \
  -F "enterprise_id=$ENTERPRISE_ID" \
  -F "csv_file=@devices.csv" \
  | jq -r '.id')

# 2. Poll until terminal (back off the calls — imports take seconds
#    to minutes depending on size).
while true; do
  STATE=$(curl -s -H "Authorization: Bearer $TOKEN" \
    "https://dev-au-03.nnnco.io/api/v4/registry/bulk-enrolments/$BATCH/" \
    | jq -r '.state')
  case "$STATE" in
    succeeded|partial|failed) break ;;
  esac
  sleep 5
done

# 3. If any rows failed, pull the failures CSV.
if [ "$STATE" != "succeeded" ]; then
  curl -s -H "Authorization: Bearer $TOKEN" \
    "https://dev-au-03.nnnco.io/api/v4/registry/bulk-enrolments/$BATCH/failures/" \
    -o failures.csv
fi