# Passport links back into the ERP

> Follow the change feed with a read key and store each unit's GS1 Digital Link, passport status and QR code on its serial number record in the ERP.

Labels, delivery notes and service tools in your ERP need the link to each battery's passport. In this workflow
the middleware follows BatteryPass's [change feed](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes) with a key of
scope `read`, reads the unit and its passport, and writes the GS1 Digital Link and the passport status to the
serial number record in the ERP. Nothing is written to BatteryPass.

```mermaid
sequenceDiagram
  participant MW as Middleware
  participant BP as BatteryPass API
  participant ERP
  loop every few minutes
    MW->>BP: listChanges with stored cursor and passport types (scope read)
    BP-->>MW: changes oldest first, next_cursor
    MW->>BP: getBattery (scope read)
    MW->>BP: getPassport (scope read)
    MW->>BP: getPassportQr when a label is needed (scope read)
    MW->>ERP: store Digital Link and status on the serial record
    MW->>MW: store next_cursor after the ERP accepted
  end
```

**Trigger:** A passport is published, re-published or withdrawn.

**Key scopes:** read

| # | Who        | What                                                                                                                       | Operation                                                                                                       |
| - | ---------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 1 | Middleware | Polls the change feed with the stored cursor, for example every five minutes, filtered to passport changes.                | [`listChanges`](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes.md) (Scope: read)        |
| 2 | Middleware | Reads the unit to get its serial number and model, which find the record in the ERP.                                       | [`getBattery`](https://passport.kabin.sh/docs/en/api-reference/catalog/get-battery.md) (Scope: read)          |
| 3 | Middleware | Reads the unit's passport with its status, current version and GS1 Digital Link.                                           | [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport.md) (Scope: read)      |
| 4 | Middleware | Downloads the QR code (PNG or SVG) when a label is to be printed.                                                          | [`getPassportQr`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr.md) (Scope: read) |
| 5 | ERP        | Stores the Digital Link and the passport status on the serial number or equipment record; label printing reads them there. |                                                                                                                 |
| 6 | Middleware | Stores the cursor only after the ERP accepted the update; after a crash it repeats changes instead of losing them.         |                                                                                                                 |

**Security**

- A key with the scope read only; it cannot change anything.
- The feed delivers thin events without values; the middleware fetches the data afterwards with the same key, so an intercepted feed reveals nothing.
- Changes arrive at least once; writing to the ERP is idempotent (same link, same status).

**Data minimisation**

- Only the Digital Link, the passport status and the version number go into the ERP, no passport content.

## The change feed

`GET /api/v1/changes` returns your organisation's changes oldest first. Each change names only what happened and to
which entity; read the entity for its current state.

- **types** `string[]` (query): Comma-separated change actions; all when omitted. For this workflow: `passport.published`,
  `passport.republished`, `passport.withdrawn`.

* **cursor** `string` (query): The `next_cursor` of your last page; without it the feed starts at the beginning of your organisation's history.
* **limit** `integer` (query, default: 50): At most 200.

```json title="200"
{
  "items": [
    {
      "id": "0192f6d2-3c4e-7a5b-8c9d-0e1f2a3b4c5d",
      "occurred_at": "2026-10-24T09:12:40Z",
      "entity_type": "battery",
      "entity_id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f",
      "action": "passport.published"
    }
  ],
  "next_cursor": "eyJ4IjoxODQ0MiwiaSI6IjAxOTJmNmQyLTNjNGUtN2E1Yi04YzlkLTBlMWYyYTNiNGM1ZCJ9"
}
```

| `action`                                                           | `entity_type`        | Read next                                                                                                                                                                                               |
| ------------------------------------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `battery.created`                                                  | `battery`            | [`getBattery`](https://passport.kabin.sh/docs/en/api-reference/catalog/get-battery)                                                                                                                   |
| `passport.published`, `passport.republished`, `passport.withdrawn` | `battery` (the unit) | [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport)                                                                                                               |
| `condition.updated`, `lifecycle_event.recorded`                    | `battery`            | [`getCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/get-condition), [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) |
| `model_version.approved`                                           | `model_version`      | [`getModelVersion`](https://passport.kabin.sh/docs/en/api-reference/catalog/get-model-version)                                                                                                        |
| `import.committed`, `import.approved`                              | `import`             | a key with scope `import`: [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import)                                                                                          |

Three rules make polling reliable:

- **`next_cursor` is set whenever the page has items, also on the last page.** Store it and send it next time. An
  empty page has `next_cursor: null`: keep the cursor you sent.
- **A change appears only once every older transaction has finished.** A cursor therefore never jumps over a change
  that was committed late. A change may appear a few seconds after it happened.
- **Store the cursor only after the ERP accepted the updates of the page.** After a crash you see the same changes
  again (at least once); writing the same link and status twice is harmless.

## Poll and write back

```bash title="cURL"
curl -sS "$BATTERYPASS_URL/api/v1/changes?types=passport.published,passport.republished,passport.withdrawn&limit=100&cursor=$CURSOR" \
  -H "Authorization: Bearer $BATTERYPASS_READ_KEY"

curl -sS "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/passport" \
  -H "Authorization: Bearer $BATTERYPASS_READ_KEY" | jq '{status, passport_id, gs1_digital_link, version: .current_version.version_no}'

curl -sS -o label.svg "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/passport/qr?format=svg" \
  -H "Authorization: Bearer $BATTERYPASS_READ_KEY"
```

```ts title="TypeScript"
// passport-links.mts: run every few minutes
import { existsSync, readFileSync, writeFileSync } from 'node:fs';

const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_READ_KEY}` };
const STATE = 'passport-links.cursor';

type Change = { id: string; entity_id: string; action: string };

// Placeholder: write the link and status to the serial number record in your ERP (idempotent).
async function updateErp(serial: string, gtin: string | undefined, link: string | undefined, status: string) {
  console.log(serial, gtin, link, status);
}

const get = async (path: string) => {
  const res = await fetch(`${BASE}${path}`, { headers });
  if (!res.ok) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`);
  return res.json();
};

let cursor = existsSync(STATE) ? readFileSync(STATE, 'utf8') : '';
for (;;) {
  const query = new URLSearchParams({ types: 'passport.published,passport.republished,passport.withdrawn', limit: '100' });
  if (cursor) query.set('cursor', cursor);
  const page: { items: Change[]; next_cursor: string | null } = await get(`/changes?${query}`);
  for (const change of page.items) {
    const passport = await get(`/batteries/${change.entity_id}/passport`);
    await updateErp(passport.serial_number, passport.gtin, passport.gs1_digital_link, passport.status);
  }
  if (!page.next_cursor) break; // empty page: keep the stored cursor
  cursor = page.next_cursor;
  writeFileSync(STATE, cursor); // only after the ERP accepted the page
}
```

```python title="Python"
# passport_links.py: run every few minutes
import os

import requests

BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
STATE = "passport-links.cursor"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_READ_KEY"]


def update_erp(serial, gtin, link, status):
    """Placeholder: write the link and status to the serial number record in your ERP (idempotent)."""
    print(serial, gtin, link, status)


def get(path, **params):
    res = session.get(BASE + path, params=params, timeout=30)
    res.raise_for_status()
    return res.json()


cursor = open(STATE).read() if os.path.exists(STATE) else None
while True:
    params = {"types": "passport.published,passport.republished,passport.withdrawn", "limit": 100}
    if cursor:
        params["cursor"] = cursor
    page = get("/changes", **params)
    for change in page["items"]:
        passport = get(f"/batteries/{change['entity_id']}/passport")
        update_erp(passport["serial_number"], passport.get("gtin"), passport.get("gs1_digital_link"), passport["status"])
    if not page["next_cursor"]:
        break  # empty page: keep the stored cursor
    cursor = page["next_cursor"]
    with open(STATE, "w") as f:  # only after the ERP accepted the page
        f.write(cursor)
```

The passport carries what the ERP needs: `serial_number` and `gtin` to find the record, `gs1_digital_link` for the
label (`{resolver}/01/{gtin}/21/{serial}`), `status` (`published`, `update_needed`, `withdrawn`, …), `passport_id`
and `current_version.version_no`. A label printer takes the QR code from
[`getPassportQr`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr) (`format=png` with `size`, or `format=svg`);
it renders the same Digital Link. [`getBattery`](https://passport.kabin.sh/docs/en/api-reference/catalog/get-battery) adds the model,
model version and dates when your ERP record needs them.

The first run without a cursor walks through your whole history; filter with `types` and let it run once before
you schedule it. How the Digital Link resolves is described in
[Resolve a Digital Link](https://passport.kabin.sh/docs/en/guides/resolve-digital-link).

## What goes into the ERP

Only the Digital Link, the passport status and the version number. Passport content stays in BatteryPass: the
public page and the public JSON behind the Digital Link are always current, and restricted data must not be copied
into systems with a wider audience.

## Failure handling

| Answer                             | Meaning                              | What to do                                                                                                                                  |
| ---------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_error` on `cursor` | The stored cursor cannot be used     | Delete it; the next run starts at the beginning and repeats changes, which is harmless because writing the same link twice changes nothing. |
| `400 validation_error` on `types`  | An unknown change action             | Fix the list.                                                                                                                               |
| `401 unauthenticated`              | Key missing, unknown or revoked      | Check the secret; alert.                                                                                                                    |
| `403 forbidden`                    | The key lacks the scope `read`       | Use the read key.                                                                                                                           |
| `404 not_found` on a unit          | The unit is not in your organisation | Skip it and log the change id.                                                                                                              |
| `409 conflict` on `getPassportQr`  | The identifiers are not locked yet   | Ask again after the next `passport.published`.                                                                                              |
| `429 rate_limited`                 | Too many requests                    | Wait `Retry-After` seconds; keep the cursor.                                                                                                |
| `5xx`, timeout                     | Unknown outcome                      | Retry with backoff; the cursor still points before the page.                                                                                |
