# Sync units into your system

> Copy the units of your organisation into an ERP, MES or database with cursor paging, resume after an interruption and detect what changed.

[`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries) returns the units (individual batteries) of your
organisation page by page. This guide reads all of them, stores the cursor so an interrupted run can continue,
and shows how to tell which units changed since the last run. You need a key with the scope `read`.

> [!TIP]
> To notice changes without reading every unit again, poll the change feed
> [`listChanges`](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes); the workflow
> [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp) shows how.

## The list

```bash title="cURL"
curl -sS "$BATTERYPASS_URL/api/v1/batteries?limit=200&passport_status=published" \
  -H "Authorization: Bearer $BATTERYPASS_API_KEY"
```

```json title="200 (shortened)"
{
  "items": [
    {
      "id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f",
      "serial_number": "SV2027-000184",
      "battery_model_id": "0192f0a1-6c2e-7d4b-9a10-3e5f7b2c8d41",
      "model_version_id": "0192f0a2-1b3c-7e5d-8f60-7a8b9c0d1e2f",
      "battery_status": "original",
      "passport_status": "published",
      "manufacturing_date": "2026-06-18",
      "facility_id": "0192f09e-4a5b-7c6d-9e8f-0a1b2c3d4e5f",
      "identifiers_locked_at": "2026-07-02T10:15:00Z",
      "successor_battery_ids": [],
      "row_version": 4,
      "created_at": "2026-06-20T08:00:12Z",
      "updated_at": "2026-07-02T10:15:00Z"
    }
  ],
  "next_cursor": "eyJTZXJpYWxOdW1iZXIiOiJTVjIwMjctMDAwMTg0IiwiSUQiOiIwMTkyYTNmMC01YjdjLTdkMjEtOGU0Zi02YTJiOWMxZDNlNWYifQ"
}
```

Units come ordered by serial number, then id. `limit` is 1 to 200 (default 50). `next_cursor` is `null` on the
last page. These filters narrow the list and can be combined:

| Parameter          | Only units                                                                                                                       |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `battery_model_id` | of this battery model                                                                                                            |
| `model_version_id` | of this model version                                                                                                            |
| `passport_status`  | whose passport has this status: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled` |
| `serial_prefix`    | whose serial number starts with this text (1 to 50 characters)                                                                   |
| `import_id`        | created by this spreadsheet import                                                                                               |

There is no filter for a change date. Change detection happens on your side, as described below.

## Read every page

Keep the same filters for every page of a run, and store the cursor only after you have stored the page it
came with. When the run is complete, delete the cursor so the next run starts at the beginning.

```ts title="TypeScript"
// sync-units.mts: npx tsx sync-units.mts
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';

const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` };
const STATE = 'units.cursor'; // lets an interrupted pass continue where it stopped

type Unit = { id: string; serial_number: string; passport_status: string; row_version: number };

// Placeholder: insert or update the unit in your database, keyed by id.
function upsert(unit: Unit) {
  console.log(unit.id, unit.serial_number, unit.passport_status, unit.row_version);
}

let cursor: string | null = existsSync(STATE) ? readFileSync(STATE, 'utf8') : null;
do {
  const query = new URLSearchParams({ limit: '200' });
  if (cursor) query.set('cursor', cursor);
  const res = await fetch(`${BASE}/batteries?${query}`, { headers });
  if (!res.ok) throw new Error(`listBatteries: ${res.status} ${(await res.json()).code}`);
  const page: { items: Unit[]; next_cursor: string | null } = await res.json();
  page.items.forEach(upsert);
  cursor = page.next_cursor;
  if (cursor) writeFileSync(STATE, cursor); // only after the page is stored
} while (cursor);
rmSync(STATE, { force: true }); // pass complete: the next one starts at the beginning
```

```python title="Python"
# sync_units.py
import os

import requests

BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
STATE = "units.cursor"  # lets an interrupted pass continue where it stopped
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"]


def upsert(unit):
    """Placeholder: insert or update the unit in your database, keyed by id."""
    print(unit["id"], unit["serial_number"], unit["passport_status"], unit["row_version"])


cursor = None
if os.path.exists(STATE):
    with open(STATE) as f:
        cursor = f.read()
while True:
    params = {"limit": 200, **({"cursor": cursor} if cursor else {})}
    res = session.get(f"{BASE}/batteries", params=params, timeout=30)
    res.raise_for_status()
    page = res.json()
    for unit in page["items"]:
        upsert(unit)
    cursor = page["next_cursor"]
    if not cursor:
        break
    with open(STATE, "w") as f:  # only after the page is stored
        f.write(cursor)
if os.path.exists(STATE):
    os.remove(STATE)  # pass complete: the next one starts at the beginning
```

```go title="Go"
// sync-units: go run .
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"net/url"
	"os"
)

const state = "units.cursor" // lets an interrupted pass continue where it stopped

type unit struct {
	ID             string `json:"id"`
	SerialNumber   string `json:"serial_number"`
	PassportStatus string `json:"passport_status"`
	RowVersion     int64  `json:"row_version"`
}

// upsert is a placeholder: insert or update the unit in your database, keyed by id.
func upsert(u unit) {
	fmt.Println(u.ID, u.SerialNumber, u.PassportStatus, u.RowVersion)
}

func main() {
	base, key := os.Getenv("BATTERYPASS_URL")+"/api/v1", os.Getenv("BATTERYPASS_API_KEY")
	saved, err := os.ReadFile(state)
	if err != nil && !errors.Is(err, fs.ErrNotExist) {
		log.Fatal(err)
	}
	cursor := string(saved)
	for {
		q := url.Values{"limit": {"200"}}
		if cursor != "" {
			q.Set("cursor", cursor)
		}
		req, _ := http.NewRequest(http.MethodGet, base+"/batteries?"+q.Encode(), nil)
		req.Header.Set("Authorization", "Bearer "+key)
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			log.Fatal(err)
		}
		if res.StatusCode != http.StatusOK {
			log.Fatalf("listBatteries: %s", res.Status)
		}
		var page struct {
			Items      []unit  `json:"items"`
			NextCursor *string `json:"next_cursor"`
		}
		err = json.NewDecoder(res.Body).Decode(&page)
		res.Body.Close()
		if err != nil {
			log.Fatal(err)
		}
		for _, u := range page.Items {
			upsert(u)
		}
		if page.NextCursor == nil {
			break
		}
		cursor = *page.NextCursor
		if err := os.WriteFile(state, []byte(cursor), 0o600); err != nil { // only after the page is stored
			log.Fatal(err)
		}
	}
	_ = os.Remove(state) // pass complete: the next one starts at the beginning
}
```

## What the cursor is, and what it is not

- The cursor is opaque: store it as it is and send it back unchanged. It does not expire.
- It marks a position in the serial-number order, not a point in time. A unit created during a run whose serial
  number sorts before the cursor is not in the rest of that run; the next run finds it. So do not use a stored
  cursor to ask "what is new since last time": every run is a full pass, and the cursor only lets an
  interrupted pass continue.
- If BatteryPass rejects a stored cursor (`400 validation_error` on `cursor`), delete it and start a new pass.

[Pagination](https://passport.kabin.sh/docs/en/pagination) describes the rules for all lists.

## Detect changes

Compare each unit with what you stored in the previous run:

| What changed                                                                                              | How you see it                                                                                                                                |
| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| A new unit                                                                                                | An `id` you have not stored yet                                                                                                               |
| The unit's own data (serial number, dates, facility, `battery_status`, model version, locked identifiers) | `row_version` is higher and `updated_at` is later                                                                                             |
| The passport status, e.g. `published` → `update_needed`                                                   | `passport_status` differs; this does not change `row_version`                                                                                 |
| A new passport version                                                                                    | `current_version.version_no` of [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport) is higher           |
| Values of the unit                                                                                        | `revision` and `recorded_at` per field in [`listBatteryValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-values) |

Units are never deleted through the API; a battery at the end of its life shows it in `battery_status` and
`passport_status`. Reading the values of every unit costs one request per unit: limit it to the units whose
status or version changed, or spread it over several runs. The recipe
[Data warehouse sync](https://passport.kabin.sh/docs/en/recipes/data-warehouse-sync) writes units and values to JSON Lines.

## Limits

All keys of your organisation share 600 requests per minute. A full pass over 10 000 units with `limit=200` takes
50 requests; reading values for each unit adds 10 000. Answer `429 rate_limited` by waiting for `Retry-After`
([Rate limits](https://passport.kabin.sh/docs/en/rate-limits), [Handle errors and retries](https://passport.kabin.sh/docs/en/guides/handle-errors)).
