# BatteryPass developer documentation > Integration API of BatteryPass for EU digital battery passports: read models, units, values, passports and exports with an organisation API key, follow changes, stage spreadsheet imports, report condition data and lifecycle events, and verify public passports. # Introduction > What the BatteryPass integration API does, what stays in the portal, and where to start. BatteryPass is a platform for digital battery passports under the EU Battery Regulation (Regulation (EU) 2023/1542). Manufacturers maintain battery models, units and their data in the BatteryPass portal, have the data reviewed and publish signed passports. This documentation covers the **integration API**: the part of BatteryPass that your own systems (ERP, MES, battery management system, data warehouse) use with an API key. ## What the API covers | Area | With a key | Scope | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | | Battery models, model versions, units | Read, units also as a spreadsheet export | `read` | | Values | Read with provenance (source, review status, revisions, supporting documents) and completeness | `read` | | Evidence and facilities | Read document metadata, download files, list manufacturing facilities | `read` | | Passports | Read the current state, published versions, differences between versions, exports (BatteryPass JSON, PDF, QR code) and the registration status | `read` | | Field registry | Read version, schema and status | `read` | | Condition data | Read, and report measurements such as state of charge or capacity fade | `read`, `condition` | | Lifecycle events | Read, and report events such as an inspection or a repair | `read`, `condition` | | Changes | Follow what changed in the organisation (change feed for polling) | `read` | | Imports | Stage spreadsheet imports of units: upload, map, validate; members commit and approve them | `import` | | Public passports | Read the public tier, check signatures, download public documents | no key | Every operation, with its parameters, responses and required scope, is in the [API reference](https://passport.kabin.sh/docs/en/api-reference). ## What stays in the portal - **Declared data** (models, units, values, documents) is entered by members in the portal or imported from a spreadsheet there. The API does not create or change declared data. - **Review, approval and publication** stay with members. By default the four-eyes principle applies: the person who entered a value cannot approve it. - **Members, API keys and organisation settings** are managed in the portal by owners and administrators. Condition data reported with a key is the exception to the review step: it is stored as an approved measurement with its source and measurement time, and a published passport is updated as your organisation's settings say. See [Condition data and lifecycle](https://passport.kabin.sh/docs/en/concepts/condition-and-lifecycle). > [!NOTE] > **EU registry** > > The EU registry for digital product passports does not yet offer an interface for economic operators. > BatteryPass submits nothing to it so far, and `getRegistrySubmissionStatus` reports `not_available`. ## How the parts fit together ```mermaid flowchart LR Portal["Portal: members enter, review, publish"] --> BP[("BatteryPass")] Import["Spreadsheet import in the portal"] --> BP BP -- "API key, scope read" --> ERP["Your systems: ERP, data warehouse"] BMS["BMS or service partner system"] -- "API key, scope condition" --> BP BP --> Public["Public passport: QR code, page, JSON, no key"] ``` ## Start here [**Quickstart**](https://passport.kabin.sh/docs/en/quickstart): Create a key, make your first request and read a passport. [**Authentication**](https://passport.kabin.sh/docs/en/authentication): Organisation keys, scopes and what a key may do. [**Guides**](https://passport.kabin.sh/docs/en/guides): Step-by-step instructions for common integration tasks. [**Recipes**](https://passport.kabin.sh/docs/en/recipes): Complete, short programs to copy and adapt. [**API reference**](https://passport.kabin.sh/docs/en/api-reference): Every operation of the published contract, with a playground. --- # Quickstart > Create an API key, make your first request and read a passport in a few minutes. You need: - the BatteryPass address of your organisation, for example `https://batterypass.example.com`; - the owner role in that organisation, or an owner who creates the key for you; - `curl` and `jq`, or Node.js 20+ (the TypeScript samples are ES modules, e.g. `npx tsx quickstart.mts`), Python 3 with `requests`, or Go. 1. **Create an API key** An owner signs in to the BatteryPass portal, opens **Settings → API keys** and chooses **Create API key**. Give the key a name that says which system uses it, and select the scope **Read** (`read`). The scope **Report condition data** (`condition`) is only for systems that report measurements and lifecycle events. The secret starts with `bp_live_` and is shown **only once**. Copy it into your secret store right away; it cannot be displayed again. Keys are managed only in the portal, not through the API. 2. **Set two environment variables** ```bash title="Shell" export BATTERYPASS_URL="https://batterypass.example.com" export BATTERYPASS_API_KEY="bp_live_..." ``` `BATTERYPASS_URL` is your organisation's BatteryPass address without a trailing slash. Every sample on this site reads these two variables. More in [Environments and base URL](https://passport.kabin.sh/docs/en/environments). 3. **Make your first request** List the first five battery models of your organisation with [`listBatteryModels`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-battery-models): ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/battery-models?limit=5" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` ```json title="200" { "items": [ { "id": "0192f0a1-6c2e-7d4b-9a10-3e5f7b2c8d41", "name": "SolarVolt Home 10", "sku": "SV-H10", "gtin": "04012345000023", "category": "stationary", "archived_at": null, "row_version": 3, "created_at": "2026-01-08T09:12:44Z", "updated_at": "2026-03-02T15:40:10Z" } ], "next_cursor": null } ``` A `401` means the header is missing or the key is unknown or revoked; a `403` means the key lacks the `read` scope. See [Authentication](https://passport.kabin.sh/docs/en/authentication) and [Errors](https://passport.kabin.sh/docs/en/errors). 4. **List battery models in code** ```ts title="TypeScript" const res = await fetch(`${process.env.BATTERYPASS_URL}/api/v1/battery-models?limit=50`, { headers: { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }, }); if (!res.ok) throw new Error(`listBatteryModels: ${res.status} ${await res.text()}`); const page: { items: { id: string; name: string; gtin: string | null }[]; next_cursor: string | null } = await res.json(); for (const model of page.items) console.log(model.name, model.gtin ?? '(no GTIN)', model.id); if (page.next_cursor) console.log('More models on the next page.'); ``` ```python title="Python" import os import requests res = requests.get( f"{os.environ['BATTERYPASS_URL']}/api/v1/battery-models", headers={"Authorization": f"Bearer {os.environ['BATTERYPASS_API_KEY']}"}, params={"limit": 50}, timeout=30, ) res.raise_for_status() page = res.json() for model in page["items"]: print(model["name"], model["gtin"] or "(no GTIN)", model["id"]) if page["next_cursor"]: print("More models on the next page.") ``` ```go title="Go" package main import ( "encoding/json" "fmt" "log" "net/http" "os" "time" ) func main() { req, err := http.NewRequest(http.MethodGet, os.Getenv("BATTERYPASS_URL")+"/api/v1/battery-models?limit=50", nil) if err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { log.Fatalf("listBatteryModels: %s", res.Status) } var page struct { Items []struct { ID string `json:"id"` Name string `json:"name"` GTIN *string `json:"gtin"` } `json:"items"` NextCursor *string `json:"next_cursor"` } if err := json.NewDecoder(res.Body).Decode(&page); err != nil { log.Fatal(err) } for _, m := range page.Items { gtin := "(no GTIN)" if m.GTIN != nil { gtin = *m.GTIN } fmt.Println(m.Name, gtin, m.ID) } if page.NextCursor != nil { fmt.Println("More models on the next page.") } } ``` Lists return at most `limit` items and a `next_cursor`; [Pagination](https://passport.kabin.sh/docs/en/pagination) shows how to read all pages. 5. **Read the passport of a unit** Find a unit with a published passport with [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries), then read its passport with [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport): ```bash title="cURL" BATTERY_ID=$(curl -sS "$BATTERYPASS_URL/api/v1/batteries?passport_status=published&limit=1" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" | jq -r '.items[0].id') curl -sS "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` ```ts title="TypeScript" const base = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }; const units = await fetch(`${base}/batteries?passport_status=published&limit=1`, { headers }); if (!units.ok) throw new Error(`listBatteries: ${units.status}`); const { items }: { items: { id: string }[] } = await units.json(); if (items.length === 0) throw new Error('No unit with a published passport yet.'); const res = await fetch(`${base}/batteries/${items[0].id}/passport`, { headers }); if (!res.ok) throw new Error(`getPassport: ${res.status}`); const passport = await res.json(); console.log(passport.passport_id, passport.status, passport.public_url); ``` ```python title="Python" import os import requests base = f"{os.environ['BATTERYPASS_URL']}/api/v1" headers = {"Authorization": f"Bearer {os.environ['BATTERYPASS_API_KEY']}"} units = requests.get(f"{base}/batteries", headers=headers, params={"passport_status": "published", "limit": 1}, timeout=30) units.raise_for_status() items = units.json()["items"] if not items: raise SystemExit("No unit with a published passport yet.") res = requests.get(f"{base}/batteries/{items[0]['id']}/passport", headers=headers, timeout=30) res.raise_for_status() passport = res.json() print(passport["passport_id"], passport["status"], passport.get("public_url")) ``` ```go title="Go" package main import ( "encoding/json" "fmt" "log" "net/http" "os" "time" ) var client = &http.Client{Timeout: 30 * time.Second} // get sends an authorised GET to the API and decodes the JSON answer into out. func get(path string, out any) error { req, err := http.NewRequest(http.MethodGet, os.Getenv("BATTERYPASS_URL")+"/api/v1"+path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) res, err := client.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return fmt.Errorf("GET %s: %s", path, res.Status) } return json.NewDecoder(res.Body).Decode(out) } func main() { var units struct { Items []struct { ID string `json:"id"` } `json:"items"` } if err := get("/batteries?passport_status=published&limit=1", &units); err != nil { log.Fatal(err) } if len(units.Items) == 0 { log.Fatal("No unit with a published passport yet.") } var passport struct { PassportID string `json:"passport_id"` Status string `json:"status"` PublicURL string `json:"public_url"` } if err := get("/batteries/"+units.Items[0].ID+"/passport", &passport); err != nil { log.Fatal(err) } fmt.Println(passport.PassportID, passport.Status, passport.PublicURL) } ``` ```json title="200" { "battery_id": "0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", "status": "published", "serial_number": "SV2027-000001", "gtin": "04012345000023", "passport_id": "urn:dpp:0192f3c4-7e1a-7b2c-9d3e-5f6a7b8c9d0e", "gs1_digital_link": "https://batterypass.example.com/01/04012345000023/21/SV2027-000001", "public_url": "https://batterypass.example.com/pass/0192f3c4-7e1a-7b2c-9d3e-5f6a7b8c9d0e", "first_published_at": "2026-03-10T08:30:00Z", "identifiers_locked_at": "2026-03-10T08:30:00Z", "row_version": 4 } ``` The response also carries `current_version` with the version number, the hashes and the signing key of the current version. A unit that was never published has a passport in status `draft` without `passport_id`. 6. **Read the public passport without a key** The public tier of a published passport needs no key. [`getPublicPassport`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport) takes the UUID part of `passport_id`, without the prefix `urn:dpp:`: ```bash title="cURL" PASSPORT_UUID=$(curl -sS "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" | jq -r '.passport_id | sub("^urn:dpp:"; "")') curl -sS "$BATTERYPASS_URL/api/v1/public/passports/$PASSPORT_UUID" | jq '{passport_id, status, links}' ``` ```ts title="TypeScript" // passport_id from getPassport const passportId = 'urn:dpp:0192f3c4-7e1a-7b2c-9d3e-5f6a7b8c9d0e'; const uuid = passportId.replace(/^urn:dpp:/, ''); const res = await fetch(`${process.env.BATTERYPASS_URL}/api/v1/public/passports/${uuid}`); if (!res.ok) throw new Error(`getPublicPassport: ${res.status}`); const pub = await res.json(); console.log(pub.status, pub.document?.model_name, pub.links.page); ``` ```python title="Python" import os import requests passport_id = "urn:dpp:0192f3c4-7e1a-7b2c-9d3e-5f6a7b8c9d0e" # from getPassport uuid = passport_id.removeprefix("urn:dpp:") res = requests.get(f"{os.environ['BATTERYPASS_URL']}/api/v1/public/passports/{uuid}", timeout=30) res.raise_for_status() pub = res.json() print(pub["status"], pub.get("document", {}).get("model_name"), pub["links"]["page"]) ``` ```go title="Go" package main import ( "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" ) func main() { passportID := "urn:dpp:0192f3c4-7e1a-7b2c-9d3e-5f6a7b8c9d0e" // from getPassport uuid := strings.TrimPrefix(passportID, "urn:dpp:") client := &http.Client{Timeout: 30 * time.Second} res, err := client.Get(os.Getenv("BATTERYPASS_URL") + "/api/v1/public/passports/" + uuid) if err != nil { log.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { log.Fatalf("getPublicPassport: %s", res.Status) } var pub struct { Status string `json:"status"` Document *struct { ModelName string `json:"model_name"` } `json:"document"` Links struct { Page string `json:"page"` } `json:"links"` } if err := json.NewDecoder(res.Body).Decode(&pub); err != nil { log.Fatal(err) } model := "(no content)" if pub.Document != nil { model = pub.Document.ModelName } fmt.Println(pub.Status, model, pub.Links.Page) } ``` `document` is the signed public projection of the current version; `verification` holds its Ed25519 signature. Passports that are not public (for example `withdrawn`) answer with their status and no `document`. Public reads are cached for five minutes and limited to 300 requests per minute per client address. ## Next steps [**Authentication**](https://passport.kabin.sh/docs/en/authentication): Scopes, a key's rights, rotation and 401 versus 403. [**Errors**](https://passport.kabin.sh/docs/en/errors): Problem Details, codes and the request id. [**Push condition data**](https://passport.kabin.sh/docs/en/guides/push-condition-data): Report measurements from a battery management system. [**Verify a signature**](https://passport.kabin.sh/docs/en/guides/verify-signature): Check a passport offline with the public keys. --- # Authentication > Organisation API keys, their scopes, what a key may do and how to rotate it. Every operation of the API needs an API key of your organisation, except the public passport reads (tag `public`), which need none. ## Send the key Send the key in the `Authorization` header with the `Bearer` scheme: ```http title="Header" Authorization: Bearer bp_live_... ``` ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/registry" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` ```ts title="TypeScript" const res = await fetch(`${process.env.BATTERYPASS_URL}/api/v1/registry`, { headers: { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }, }); console.log(res.status, await res.json()); ``` ```python title="Python" import os import requests res = requests.get( f"{os.environ['BATTERYPASS_URL']}/api/v1/registry", headers={"Authorization": f"Bearer {os.environ['BATTERYPASS_API_KEY']}"}, timeout=30, ) print(res.status_code, res.json()) ``` ```go title="Go" package main import ( "fmt" "io" "log" "net/http" "os" "time" ) func main() { req, err := http.NewRequest(http.MethodGet, os.Getenv("BATTERYPASS_URL")+"/api/v1/registry", nil) if err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) fmt.Println(res.Status, string(body)) } ``` The API reads keys only from this header. Never put a key into a URL: query strings and paths end up in logs, browser histories and proxies. ## Organisation keys - A key belongs to the **organisation**, not to a member. It keeps working when the member who created it leaves. - **Owners** create and revoke keys in the portal under **Settings → API keys**. Key management is not part of the API. - The secret is `bp_live_` followed by 43 characters. It is shown **once**, when the key is created. BatteryPass stores only a SHA-256 hash and shows the first 12 characters as a prefix so you can tell keys apart. - An organisation has at most **25 active keys**. A revoked key stays listed with its revocation time and never works again. - Keys have **no expiry date**. You end a key by revoking it (see [Rotate a key](#rotate-a-key)). ## Scopes A key has one or more scopes. Each operation names the scope it needs in `x-api-key-scope` in the contract and on its page in the [API reference](https://passport.kabin.sh/docs/en/api-reference). | Scope | Allows | Operations | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read` | Reading what a viewer of the organisation sees: models, versions, units, values with provenance and completeness, evidence, facilities, passports with versions, diffs and exports, lifecycle events, condition data, the registry | Every operation with `x-api-key-scope: read` | | `condition` | Reporting condition data and lifecycle events | [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition), [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event) | | `import` | Staging spreadsheet imports of units: upload, map, validate and follow them; members commit and approve them in the portal (ADR-026) | [`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import), [`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping), [`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import), [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import), [`listImports`](https://passport.kabin.sh/docs/en/api-reference/imports/list-imports) | The scopes do not include each other. A battery management system that reports measurements and also reads the current condition with [`getCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/get-condition) needs both. To list every operation with its scope, read the contract of your deployment: ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/openapi.json" \ | jq -r '.paths[][] | objects | select(has("operationId")) | "\(.operationId)\t\(.["x-api-key-scope"] // "public")"' ``` ## What a key may do A key acts in its organisation with the rights of a **viewer**. Where an operation's description names a role ("any member", "editors and above"), a key counts as a viewer. The exceptions: the two operations of the `condition` scope accept a key with that scope, and the import operations of the `import` scope accept a key with that one. A key never: - creates or changes declared data (models, units, values, documents); - reviews, approves, publishes or withdraws anything, and never commits or approves an import, also one it staged; - manages members, API keys or organisation settings. These stay with members in the portal, so the four-eyes principle and the publication checks keep their meaning. What a key reports is recorded with the source `api`, and the lifecycle event of the report names the key in its `api_key` member. ## Session and key together A request that carries both a portal session cookie and an API key is refused with `401`. This can happen when you try requests in a browser in which you are signed in to the portal. Use a client without cookies, such as `curl` or your server code. ## 401 or 403 | Status | `code` | Cause | What to do | | ------ | ----------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `401` | `unauthenticated` | The header is missing or malformed, the key is unknown or revoked, or the request also carries a session cookie | Check the header and the environment variable; ask an owner for a new key if it was revoked | | `403` | `forbidden` | The key lacks the operation's scope, or the action needs more than a viewer's rights | Use a key with the right scope; actions beyond a viewer's rights are done in the portal | | `429` | `rate_limited` | Too many attempts with unknown or revoked keys from your client address | Stop and fix the key; see [Rate limits](https://passport.kabin.sh/docs/en/rate-limits) | > [!WARNING] > Do not retry a `401` in a loop. Requests with unknown or revoked keys are limited to 30 attempts per 15 > minutes per client address; after that, further attempts with such a key get `429` instead of `401` until > the window ends. ## Rotate a key Keys do not expire, so rotate them yourself: on a regular schedule, at once when a key may have leaked, and when someone who knew it leaves. 1. **Create the new key** An owner creates a new key with the same scopes in **Settings → API keys**. 2. **Deploy it** Put the new secret into your secret store and restart or reconfigure the system that uses it. 3. **Check that the old key is idle** In the portal, the old key's "Last used" time stops changing. It is updated at most once a minute. 4. **Revoke the old key** Revoking takes effect at once and cannot be undone. ## Keep keys secret - Use keys only in server-side systems, never in browser code, mobile apps or public repositories. - Create one key per system, so you can revoke one without stopping the others. - Give each key only the scopes it needs. - The prefix `bp_live_` makes leaked keys easy to find for secret scanners. More in [Secure key handling](https://passport.kabin.sh/docs/en/guides/secure-key-handling) and [Security](https://passport.kabin.sh/docs/en/general/security). --- # Errors > How the API reports errors as RFC 9457 Problem Details with stable codes and a request id. Every error response has a `4xx` or `5xx` status and a body of type `application/problem+json` (RFC 9457 Problem Details). Your code decides by the HTTP status and the stable `code`; the texts are for people. ## Members of a problem - **type** `string` (required): Problem type. `about:blank` unless a specific type is documented. - **title** `string` (required): Short summary; for `about:blank` the HTTP status phrase, e.g. `Bad Request`. - **status** `integer` (required): The HTTP status code, repeated in the body. - **code** `string` (required): Stable, machine-readable code in snake_case, e.g. `validation_error`. See [Codes](#codes). - **detail** `string`: Explanation of this occurrence, in English. Show it in logs, do not parse it. - **instance** `string`: URI reference of the request that caused the problem, when present. - **request_id** `string`: The id of the request, equal to the `X-Request-Id` response header. Quote it when you contact support. - **errors** `object[]`: Field-level details for `validation_error`, and for `conflict` when a value must be unique. **field error** - **field** `string` (required): The body field (dotted, with list positions, e.g. `values[0].measured_at`), the query parameter or header name (e.g. `limit`), or `body` when the body as a whole is invalid (malformed JSON, an unknown property, a wrong content type). - **code** `string` (required): Stable reason code, see [Field error codes](#field-error-codes). - **message** `string` (required): Explanation in English; localise by `code`. ## Field error codes | Code | Meaning | | --------------------- | ------------------------------------------------------------------------------------------------------------------ | | `required` | A required field, parameter or header is missing. | | `too_long` | A text is longer than allowed. | | `too_short` | A text is shorter than allowed. | | `out_of_range` | A number, date or time lies outside the allowed range, e.g. a `measured_at` in the future. | | `invalid_format` | The value has the wrong format (date, UUID, pattern), or the body is not valid JSON or not `application/json`. | | `invalid_check_digit` | The check digit of a GTIN or GLN is wrong. | | `invalid_value` | The value is not allowed here: an unknown enum value, an invalid parameter or cursor, a failing cross-field check. | | `unknown_field` | The field is not known here, e.g. a field that is not condition data of the unit, or an unknown body property. | | `already_exists` | A value that must be unique is already taken (with `conflict`). | ## Examples A condition update with a measurement time in the future ([`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition)) is refused as a whole: ```json title="400" { "type": "about:blank", "title": "Bad Request", "status": 400, "code": "validation_error", "detail": "The request failed validation.", "request_id": "4f9c2a7d1e0b5c8a3f6e2d19", "errors": [ { "field": "values[0].measured_at", "code": "out_of_range", "message": "measured_at lies in the future" } ] } ``` Too many requests: the `Retry-After` header says how many seconds to wait. ```http title="429" HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 17 X-Request-Id: 8b1e6f03c2d94a7e5f0a1b2c { "type": "about:blank", "title": "Too Many Requests", "status": 429, "code": "rate_limited", "detail": "Too many attempts. Try again later.", "request_id": "8b1e6f03c2d94a7e5f0a1b2c" } ``` ## Codes The `ProblemCode` values of the published contract. Codes marked as portal only do not occur with an API key; they are listed because the contract contains them. | Code | HTTP status | Meaning | | ---------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `validation_error` | 400 | The request is invalid. \`errors\[]\` names each affected field, query parameter or header with a stable error code. | | `invalid_token` | 400 | Portal only: an email or invitation link is unknown, used or expired. | | `unauthenticated` | 401 | No valid API key: the header is missing, the key is unknown or revoked, or the request also carries a session cookie. | | `invalid_credentials` | 401 | Portal only: the email address or password of a sign-in is wrong. | | `forbidden` | 403 | The key lacks the scope the operation needs, or a viewer's rights are not enough. | | `email_not_verified` | 403 | Portal only: the email address is not verified yet. | | `not_found` | 404 | The resource does not exist, or it belongs to another organisation. | | `method_not_allowed` | 405 | The path exists, but not with this HTTP method. | | `conflict` | 409 | The request contradicts the current state, for example the unit's battery model is archived: its data can be read but no longer changed. | | `identifiers_locked` | 409 | Portal only: the identifiers of a published unit can no longer change. | | `payload_too_large` | 413 | The request body is larger than allowed (1 MiB by default). | | `publish_blocked` | 422 | Portal only: publishing fails on open required data; \`blockers\[]\` lists it. | | `rate_limited` | 429 | Too many requests. Wait the seconds given in the \`Retry-After\` header, then try again. | | `request_closed` | 410 | Supplier and service-partner links only: the link expired, was cancelled or has been used. | | `internal_error` | 500 | An error on our side. Retry later; quote the \`request_id\` when you contact support. | | `address_lookup_unavailable` | 503 | Portal only: the address search is not reachable right now; the address can be entered by hand. | ## Request id Every response, successful or not, carries an `X-Request-Id` header. In a problem, `request_id` has the same value. The server creates the id itself and ignores an id you send. Log it with each failed request and quote it when you contact [support](https://passport.kabin.sh/docs/en/general/support). ## Handling errors - Decide by `status` and `code`, never by `title`, `detail` or `message`: those texts can change. - Expect new codes over time. Treat an unknown `code` by its HTTP status class. - Fix `400`, `401`, `403`, `404` and `409` instead of repeating the request unchanged. - Retry `429` after `Retry-After`, and `5xx` or network errors with exponential backoff. Before you repeat a write, read [Retries and idempotency](https://passport.kabin.sh/docs/en/idempotency). - Responses never contain SQL, stack traces or upstream errors; an unexpected failure is `500 internal_error` with a request id. The guide [Handle errors](https://passport.kabin.sh/docs/en/guides/handle-errors) shows complete error handling in TypeScript, Python and Go. --- # Pagination > Read long lists page by page with `limit` and an opaque `cursor`. Lists that can grow large use keyset pagination: you ask for a page with `limit`, and the answer tells you where the next page starts. ## Which operations page | Operation | Order | | ------------------------------------------------------------------------------------------------------------ | ------------------------- | | [`listBatteryModels`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-battery-models) | By name, then id | | [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries) | By serial number, then id | | [`listEvidence`](https://passport.kabin.sh/docs/en/api-reference/evidence/list-evidence) | Newest first | | [`listFacilities`](https://passport.kabin.sh/docs/en/api-reference/facilities/list-facilities) | By name, then id | | [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) | Newest first | | [`listPassportVersions`](https://passport.kabin.sh/docs/en/api-reference/passports/list-passport-versions) | Newest first | Other lists, such as values, value histories and model versions, return all items in one response. ## Request - **limit** `integer` (query, default: 50): Number of items per page, from 1 to 200. - **cursor** `string` (query): The `next_cursor` of the previous page. Leave it out for the first page. Send the operation's filters (for example `battery_model_id` or `passport_status` of `listBatteries`) unchanged with every page. ## Response - **items** `object[]` (required): The items of this page, at most `limit`. - **next_cursor** `string | null` (required): The cursor for the next page, or `null` when this is the last page. ```json title="200" { "items": [ { "id": "0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", "serial_number": "SV2027-000001" }, { "id": "0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b13", "serial_number": "SV2027-000002" } ], "next_cursor": "eyJzIjoiU1YyMDI3LTAwMDAwMiJ9" } ``` The sample shows only two members of each unit; real items carry the full schema of the operation. ## Rules for cursors - A cursor is **opaque**. Do not parse it, build it or change it; its content may change at any time. - A cursor belongs to **the query that produced it**: the same operation, the same path parameters and the same filters. Use it right away for the next page; do not keep cursors as long-term bookmarks. - `next_cursor: null` means there are no more items. - A cursor that cannot be used is `400 validation_error` on the field `cursor`; a `limit` outside 1 to 200 is a `400` on the field `limit`. - Pages are not a snapshot. Items created or changed while you walk through a list may or may not appear. For a consistent copy, page through and then reconcile by id (see [Synchronise units](https://passport.kabin.sh/docs/en/guides/sync-units)). ## Read every page The loops below read all units with a published passport, 200 per page. ```ts title="TypeScript" type Battery = { id: string; serial_number: string }; type Page = { items: T[]; next_cursor: string | null }; const base = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }; async function* listBatteries(filters: Record): AsyncGenerator { let cursor: string | null = null; do { const query = new URLSearchParams({ ...filters, 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.text()}`); const page: Page = await res.json(); yield* page.items; cursor = page.next_cursor; } while (cursor); } let count = 0; for await (const unit of listBatteries({ passport_status: 'published' })) { console.log(unit.serial_number, unit.id); count++; } console.log(`${count} units`); ``` ```python title="Python" import os import requests base = f"{os.environ['BATTERYPASS_URL']}/api/v1" session = requests.Session() session.headers["Authorization"] = f"Bearer {os.environ['BATTERYPASS_API_KEY']}" def list_batteries(**filters): params = {**filters, "limit": 200} while True: res = session.get(f"{base}/batteries", params=params, timeout=30) res.raise_for_status() page = res.json() yield from page["items"] if page["next_cursor"] is None: return params["cursor"] = page["next_cursor"] count = 0 for unit in list_batteries(passport_status="published"): print(unit["serial_number"], unit["id"]) count += 1 print(count, "units") ``` ```go title="Go" package main import ( "encoding/json" "fmt" "log" "net/http" "net/url" "os" "time" ) type battery struct { ID string `json:"id"` SerialNumber string `json:"serial_number"` } type batteryPage struct { Items []battery `json:"items"` NextCursor *string `json:"next_cursor"` } func fetchPage(client *http.Client, query url.Values) (batteryPage, error) { var page batteryPage req, err := http.NewRequest(http.MethodGet, os.Getenv("BATTERYPASS_URL")+"/api/v1/batteries?"+query.Encode(), nil) if err != nil { return page, err } req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) res, err := client.Do(req) if err != nil { return page, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return page, fmt.Errorf("listBatteries: %s", res.Status) } return page, json.NewDecoder(res.Body).Decode(&page) } func main() { client := &http.Client{Timeout: 30 * time.Second} query := url.Values{"limit": {"200"}, "passport_status": {"published"}} count := 0 for { page, err := fetchPage(client, query) if err != nil { log.Fatal(err) } for _, unit := range page.Items { fmt.Println(unit.SerialNumber, unit.ID) count++ } if page.NextCursor == nil { break } query.Set("cursor", *page.NextCursor) } fmt.Println(count, "units") } ``` > [!TIP] > Each page is one request against your organisation's limit of 600 requests per minute. Use `limit=200` for > bulk reads and see [Rate limits](https://passport.kabin.sh/docs/en/rate-limits) for long walks. --- # Rate limits > How many requests BatteryPass accepts, and how to back off after a 429. BatteryPass limits requests to protect the platform and the other organisations on it. A request over a limit is answered with `429 rate_limited` and a `Retry-After` header. ## Limits | Limit | Counted for | Window | Applies to | | ------------ | ---------------------------------------- | ---------- | --------------------------------------------------------------------------------------- | | 600 requests | All API keys of an organisation together | 1 minute | Every request with a valid key, whatever its result | | 30 attempts | One client address | 15 minutes | Requests with an unknown or revoked key | | 300 requests | One client address | 1 minute | Public passport reads, public documents and the GS1 resolver (`/01/{gtin}/21/{serial}`) | The windows are fixed: a window starts, counts up and is reset when it ends. All keys of an organisation share the 600 requests; more keys do not raise the limit. ## The 429 response ```http title="429" HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 17 X-Request-Id: 8b1e6f03c2d94a7e5f0a1b2c { "type": "about:blank", "title": "Too Many Requests", "status": 429, "code": "rate_limited", "detail": "Too many attempts. Try again later.", "request_id": "8b1e6f03c2d94a7e5f0a1b2c" } ``` `Retry-After` is the number of seconds until the current window ends. A request answered with `429` was not carried out, so you can repeat it after the wait, writes included. ## Back off Wait at least `Retry-After` seconds, add a little random jitter so that parallel workers do not all start at the same moment, and give up after a few attempts: ```ts title="TypeScript" const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Repeats a request after 429 responses, waiting as Retry-After says. async function fetchWithBackoff(url: string, init: RequestInit = {}, attempts = 5): Promise { for (let attempt = 1; ; attempt++) { const res = await fetch(url, init); if (res.status !== 429 || attempt === attempts) return res; const seconds = Number(res.headers.get('Retry-After')) || 2 ** attempt; await sleep(seconds * 1000 + Math.random() * 1000); } } const res = await fetchWithBackoff(`${process.env.BATTERYPASS_URL}/api/v1/battery-models`, { headers: { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }, }); console.log(res.status); ``` ```python title="Python" import os import random import time import requests def get_with_backoff(url, attempts=5, **kwargs): """Repeats a GET after 429 responses, waiting as Retry-After says.""" for attempt in range(1, attempts + 1): res = requests.get(url, timeout=30, **kwargs) if res.status_code != 429 or attempt == attempts: return res delay = int(res.headers.get("Retry-After", 2 ** attempt)) time.sleep(delay + random.random()) res = get_with_backoff( f"{os.environ['BATTERYPASS_URL']}/api/v1/battery-models", headers={"Authorization": f"Bearer {os.environ['BATTERYPASS_API_KEY']}"}, ) print(res.status_code) ``` ```go title="Go" package main import ( "fmt" "log" "math/rand/v2" "net/http" "os" "strconv" "time" ) // doWithBackoff repeats a request without a body after 429 responses, waiting as Retry-After says. func doWithBackoff(client *http.Client, req *http.Request, attempts int) (*http.Response, error) { for attempt := 1; ; attempt++ { res, err := client.Do(req) if err != nil || res.StatusCode != http.StatusTooManyRequests || attempt == attempts { return res, err } res.Body.Close() wait := time.Duration(1< # Retries and idempotency > Which requests you can safely repeat after a timeout, and how to avoid duplicate events. When a request times out or the connection drops, you do not know whether BatteryPass carried it out. Whether you can simply send it again depends on the operation. ## Overview | Operations | Method | Safe to repeat | Why | | ------------------------------------------------------------------------------------------------------------ | ------- | -------------------------------- | -------------------------------------------------------------------------- | | All reads, including public passport reads | `GET` | Yes | They change nothing. | | [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition) | `PATCH` | Yes, with the same `measured_at` | A measurement equal to or older than the stored one is skipped and listed. | | [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event) | `POST` | No | Every successful call records a new event. | A request answered with `429` was not carried out and can always be repeated after `Retry-After` (see [Rate limits](https://passport.kabin.sh/docs/en/rate-limits)). A `4xx` answer means the request was refused as a whole: fix it instead of repeating it. ## Reads Every `GET` of the API is safe to repeat as often as you like. Retry network errors and `5xx` responses with exponential backoff. ## Condition data `updateCondition` merges measured values into the unit's condition data. For each value, BatteryPass compares its `measured_at` with that of the stored value: - **newer** than the stored measurement, or the same time with a different value: stored, listed in `accepted`; - **the same time and the same value**: skipped with the reason `unchanged`; - **older** than the stored measurement: skipped with the reason `older_measurement`. So a repeated request with the same body changes nothing. Send it, for example: ```json title="Request" { "measured_at": "2026-10-24T02:00:00Z", "values": [ { "field_key": "condition.state_of_charge", "value": 87.5 }, { "field_key": "condition.number_of_full_cycles", "value": 412 } ] } ``` If the first attempt was stored, the repetition answers: ```json title="200" { "accepted": [], "skipped": [ { "field_key": "condition.state_of_charge", "reason": "unchanged" }, { "field_key": "condition.number_of_full_cycles", "reason": "unchanged" } ], "publication": { "outcome": "not_published" } } ``` No new lifecycle event is recorded and nothing is published by this request: `not_published` here means that this request published nothing, and the passport keeps its current version. > [!WARNING] > Set `measured_at` from the measurement, for example the time your battery management system took the > reading, and keep it when you repeat the request. A `measured_at` taken from the clock at sending time turns > every repetition into a new measurement. ## Lifecycle events `createLifecycleEvent` has no deduplication: each successful call records a new event, and the published API has no idempotency key. After a timeout, a dropped connection or a `5xx` answer, check whether the event arrived before you send it again: 1. Read [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) for the unit (newest first). 2. Look for an event with `source: "api"` and your `type`, `occurred_on` and `description`. 3. Send the event again only if it is not there. Put a reference of your own into `description`, such as the service order number, so the event is easy to recognise. BatteryPass stores the description without leading and trailing spaces. ```ts title="TypeScript" type NewEvent = { type: string; occurred_on: string; description: string }; type LifecycleEvent = NewEvent & { id: string; source: string }; const base = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}`, 'Content-Type': 'application/json', }; // Looks for an event this integration already recorded (same type, day and description). async function findEvent(batteryId: string, e: NewEvent): Promise { let cursor: string | null = null; do { const query = new URLSearchParams({ limit: '200' }); if (cursor) query.set('cursor', cursor); const res = await fetch(`${base}/batteries/${batteryId}/lifecycle-events?${query}`, { headers }); if (!res.ok) throw new Error(`listLifecycleEvents: ${res.status}`); const page: { items: LifecycleEvent[]; next_cursor: string | null } = await res.json(); const hit = page.items.find( (x) => x.source === 'api' && x.type === e.type && x.occurred_on === e.occurred_on && x.description === e.description, ); if (hit) return hit; // Newest first: once a page reaches older days, the event is not there. if (page.items.some((x) => x.occurred_on < e.occurred_on)) return undefined; cursor = page.next_cursor; } while (cursor); return undefined; } async function recordEvent(batteryId: string, e: NewEvent): Promise { for (let attempt = 1; attempt <= 3; attempt++) { let res: Response | undefined; try { res = await fetch(`${base}/batteries/${batteryId}/lifecycle-events`, { method: 'POST', headers, body: JSON.stringify(e), signal: AbortSignal.timeout(30_000), }); } catch { res = undefined; // timeout or connection error } if (res?.status === 201) return res.json(); if (res && res.status < 500) throw new Error(`createLifecycleEvent: ${res.status} ${await res.text()}`); // No answer or a 5xx: the event may have been recorded anyway. const existing = await findEvent(batteryId, e); if (existing) return existing; } throw new Error('createLifecycleEvent: no success after 3 attempts'); } const event = await recordEvent('0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12', { type: 'inspection', occurred_on: '2026-10-24', description: 'Service order SO-2026-0815: annual inspection', }); console.log(event.id); ``` ```python title="Python" import os import requests base = f"{os.environ['BATTERYPASS_URL']}/api/v1" session = requests.Session() session.headers["Authorization"] = f"Bearer {os.environ['BATTERYPASS_API_KEY']}" def find_event(battery_id, event): """Looks for an event this integration already recorded (same type, day and description).""" params = {"limit": 200} while True: res = session.get(f"{base}/batteries/{battery_id}/lifecycle-events", params=params, timeout=30) res.raise_for_status() page = res.json() for item in page["items"]: if (item["source"] == "api" and item["type"] == event["type"] and item["occurred_on"] == event["occurred_on"] and item.get("description") == event["description"]): return item # Newest first: once a page reaches older days, the event is not there. if any(item["occurred_on"] < event["occurred_on"] for item in page["items"]) or not page["next_cursor"]: return None params["cursor"] = page["next_cursor"] def record_event(battery_id, event, attempts=3): for _ in range(attempts): try: res = session.post(f"{base}/batteries/{battery_id}/lifecycle-events", json=event, timeout=30) except (requests.Timeout, requests.ConnectionError): res = None # timeout or connection error if res is not None and res.status_code == 201: return res.json() if res is not None and res.status_code < 500: raise RuntimeError(f"createLifecycleEvent: {res.status_code} {res.text}") # No answer or a 5xx: the event may have been recorded anyway. existing = find_event(battery_id, event) if existing: return existing raise RuntimeError(f"createLifecycleEvent: no success after {attempts} attempts") event = record_event("0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", { "type": "inspection", "occurred_on": "2026-10-24", "description": "Service order SO-2026-0815: annual inspection", }) print(event["id"]) ``` ```go title="Go" package main import ( "bytes" "encoding/json" "errors" "fmt" "log" "net/http" "net/url" "os" "time" ) type newEvent struct { Type string `json:"type"` OccurredOn string `json:"occurred_on"` Description string `json:"description"` } type lifecycleEvent struct { ID string `json:"id"` Type string `json:"type"` OccurredOn string `json:"occurred_on"` Description string `json:"description"` Source string `json:"source"` } var ( base = os.Getenv("BATTERYPASS_URL") + "/api/v1" client = &http.Client{Timeout: 30 * time.Second} ) // send calls the API and decodes a successful JSON answer into out; it returns the HTTP status. func send(method, path string, body, out any) (int, error) { var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { return 0, err } } req, err := http.NewRequest(method, base+path, &buf) if err != nil { return 0, err } req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := client.Do(req) if err != nil { return 0, err } defer res.Body.Close() if res.StatusCode >= 300 { return res.StatusCode, nil } return res.StatusCode, json.NewDecoder(res.Body).Decode(out) } // findEvent looks for an event this integration already recorded (same type, day and description). func findEvent(batteryID string, e newEvent) (*lifecycleEvent, error) { query := url.Values{"limit": {"200"}} for { var page struct { Items []lifecycleEvent `json:"items"` NextCursor *string `json:"next_cursor"` } status, err := send(http.MethodGet, "/batteries/"+batteryID+"/lifecycle-events?"+query.Encode(), nil, &page) if err != nil { return nil, err } if status != http.StatusOK { return nil, fmt.Errorf("listLifecycleEvents: %d", status) } older := false for _, x := range page.Items { if x.Source == "api" && x.Type == e.Type && x.OccurredOn == e.OccurredOn && x.Description == e.Description { return &x, nil } older = older || x.OccurredOn < e.OccurredOn } // Newest first: once a page reaches older days, the event is not there. if older || page.NextCursor == nil { return nil, nil } query.Set("cursor", *page.NextCursor) } } func recordEvent(batteryID string, e newEvent) (*lifecycleEvent, error) { for attempt := 1; attempt <= 3; attempt++ { var created lifecycleEvent status, err := send(http.MethodPost, "/batteries/"+batteryID+"/lifecycle-events", e, &created) if err == nil && status == http.StatusCreated { return &created, nil } if err == nil && status < 500 { return nil, fmt.Errorf("createLifecycleEvent: %d", status) } // No answer or a 5xx: the event may have been recorded anyway. existing, err := findEvent(batteryID, e) if err != nil { return nil, err } if existing != nil { return existing, nil } } return nil, errors.New("createLifecycleEvent: no success after 3 attempts") } func main() { e, err := recordEvent("0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", newEvent{ Type: "inspection", OccurredOn: "2026-10-24", Description: "Service order SO-2026-0815: annual inspection", }) if err != nil { log.Fatal(err) } fmt.Println(e.ID) } ``` The samples treat a `429` like any other refusal; combine them with the back-off from [Rate limits](https://passport.kabin.sh/docs/en/rate-limits) in production. ## The Idempotency-Key header BatteryPass knows an `Idempotency-Key` header, but only on portal operations such as publishing and bulk jobs. None of the published operations accepts it, so do not send it to the API: it has no effect there. --- # Stability and versioning > What integrations can rely on, how the API changes and where to download the contract. ## Two version numbers | Version | Where | Changes when | | ---------------- | -------------------------------------------------- | ------------------------------------------------------- | | Path version | `/api/v1` in every URL | An incompatible change to published operations is made. | | Contract version | `info.version` in the contract, currently `0.10.0` | The contract changes in any way. | The [changelog](https://passport.kabin.sh/docs/en/changelog) lists each contract version with its changes. ## What you can rely on The published contract (ADR-020) contains exactly the operations integrations may use: the operations that accept an API key and the public passport reads (tag `public`). Everything in it is the API this documentation describes and the [API reference](https://passport.kabin.sh/docs/en/api-reference) lists. The BatteryPass portal uses further, internal operations: sign-in, members, API key management, review, publishing, imports. They are not in the published contract, need a portal session and may change in any release without notice. Do not build on them. ## Compatible changes These changes can come in any release without a new path version: - new operations; - new optional query parameters and new optional properties in request bodies; - new properties in responses; - new enum values, for example a new passport status or lifecycle event type; - new problem codes and field error codes; - a new version of the field registry with new fields, labels or requirements (read it with [`getRegistryInfo`](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-info)). Write your client so that these do not break it: - ignore response properties you do not know; - handle unknown enum values with a sensible default instead of failing; - treat an unknown problem `code` by its HTTP status; - do not validate responses strictly against an older copy of the contract. ## Incompatible changes Removing or renaming an operation, parameter or property, changing a type or meaning, or making optional input required is incompatible. Such changes to published operations come only with a new path version and are announced in the [changelog](https://passport.kabin.sh/docs/en/changelog). ## Download the contract The contract is an OpenAPI 3.0 document, as JSON and YAML, without authentication: | Address | Content | | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `$BATTERYPASS_URL/api/v1/openapi.json`, `$BATTERYPASS_URL/api/v1/openapi.yaml` | The contract of the BatteryPass deployment your organisation uses | | [`/docs/openapi.json`](https://passport.kabin.sh/docs/openapi.json), [`/docs/openapi.yaml`](https://passport.kabin.sh/docs/openapi.yaml) | The contract this documentation describes | For your integration, the contract of your deployment is authoritative: it matches the version that answers your requests and can be older or newer than the one on this site. Check its version before you generate a client: ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/openapi.json" | jq -r '.info.version' ``` > [!TIP] > Generate typed clients from the contract and keep the `info.version` you generated from. Comparing it with > the deployed version in your monitoring tells you when to read the changelog. --- # Environments and base URL > Where your organisation's API lives, which addresses it serves and how to test safely. ## Base URL BatteryPass runs per deployment; there is no shared global API host. Your base URL is the BatteryPass address of your organisation, the same address you use for the portal, for example `https://batterypass.example.com`. The API lives under `/api/v1` on that address. ## Addresses | Path on your BatteryPass address | What | Authentication | | ---------------------------------------------- | ----------------------------------------------------------- | -------------- | | `/api/v1/...` | The integration API | API key | | `/api/v1/public/...` | Public passport reads | None | | `/api/v1/openapi.json`, `/api/v1/openapi.yaml` | The published contract | None | | `/01/{gtin}/21/{serial}` | GS1 Digital Link resolver, the target of the QR codes | None | | `/pass/{passportId}` | Public passport page for browsers | None | | `/.well-known/batterypass/keys.json` | Public signing keys (JWK set) to verify passport signatures | None | | `/docs/` | This documentation | None | > [!NOTE] > A deployment can print a separate resolver host into its QR codes. Take a unit's Digital Link from the > `gs1_digital_link` member of [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport) instead of > building it yourself. See [GS1 Digital Link](https://passport.kabin.sh/docs/en/concepts/digital-link). ## Conventions - **HTTPS.** Send requests, and above all keys, only over HTTPS. - **JSON.** Request and response bodies are UTF-8 JSON; send `Content-Type: application/json` with a body. Bodies are limited to 1 MiB by default (`413 payload_too_large`), and unknown properties are refused. - **Times** (`date-time`) are UTC in RFC 3339, e.g. `2026-10-24T02:00:00Z`. Send times with an offset; UTC is the safest choice. - **Dates** (`date`, e.g. `occurred_on`, `manufacturing_date`) are `YYYY-MM-DD` without a time zone. - **Ids** are UUID strings. The permanent passport identifier is `urn:dpp:{uuid}`; public routes take only the UUID. ## Configure your client All samples read two environment variables: | Variable | Value | | --------------------- | ----------------------------------------------------------------------------------------- | | `BATTERYPASS_URL` | Your BatteryPass address without a trailing slash, e.g. `https://batterypass.example.com` | | `BATTERYPASS_API_KEY` | An API key of your organisation, `bp_live_...` | ```bash title="Shell" export BATTERYPASS_URL="https://batterypass.example.com" export BATTERYPASS_API_KEY="bp_live_..." ``` ```ts title="TypeScript" const url = process.env.BATTERYPASS_URL?.replace(/\/+$/, ''); const key = process.env.BATTERYPASS_API_KEY; if (!url || !key) throw new Error('Set BATTERYPASS_URL and BATTERYPASS_API_KEY.'); if (!url.startsWith('https://')) console.warn('BATTERYPASS_URL should use HTTPS.'); export const api = `${url}/api/v1`; export const headers = { Authorization: `Bearer ${key}` }; ``` ```python title="Python" import os url = os.environ.get("BATTERYPASS_URL", "").rstrip("/") key = os.environ.get("BATTERYPASS_API_KEY", "") if not url or not key: raise SystemExit("Set BATTERYPASS_URL and BATTERYPASS_API_KEY.") if not url.startswith("https://"): print("BATTERYPASS_URL should use HTTPS.") api = f"{url}/api/v1" headers = {"Authorization": f"Bearer {key}"} ``` ```go title="Go" package main import ( "fmt" "log" "os" "strings" ) func main() { url := strings.TrimRight(os.Getenv("BATTERYPASS_URL"), "/") key := os.Getenv("BATTERYPASS_API_KEY") if url == "" || key == "" { log.Fatal("Set BATTERYPASS_URL and BATTERYPASS_API_KEY.") } if !strings.HasPrefix(url, "https://") { log.Print("BATTERYPASS_URL should use HTTPS.") } api := url + "/api/v1" fmt.Println("API base:", api) } ``` Keep the key in a secret store or your platform's secret management, not in source code or in files you commit. See [Secure key handling](https://passport.kabin.sh/docs/en/guides/secure-key-handling). ## Testing BatteryPass has no public sandbox. Test against an environment where mistakes do no harm: - **A separate test organisation**, or a separate BatteryPass deployment, with its own keys. Data is always separated by organisation, so a test key never sees or changes production data. Ask the people who run your BatteryPass deployment to set it up. - **Do not test writes against production.** A condition update by key can re-publish a public passport at once, depending on the organisation's settings, and lifecycle events cannot be deleted through the API. - **Mind what is public.** Passports published in a test organisation are public too, at the same addresses. Do not use the GTINs of real products for test data, and do not print or hand out test QR codes. - **Separate configuration.** Keep test and production values of `BATTERYPASS_URL` and `BATTERYPASS_API_KEY` apart, and give test keys only the scopes the test needs. --- # Battery models, versions and units > How BatteryPass separates the product, its technical states and the individual batteries, and where each value lives. BatteryPass keeps product data on three levels. Data that every battery of a product shares is maintained once, on the model version; data that differs per battery lives on the unit. Each unit gets its own passport, built from both levels when it is published. ```mermaid flowchart LR M["Battery model: name, SKU, GTIN, category"] --> VA["Model version A: superseded"] M --> VB["Model version B: approved"] VA --> U1["Unit SN-000184"] VB --> U2["Unit SN-000417"] U2 --> P["Passport of SN-000417"] ``` ## The three levels | Level | What it is | Main fields | Read with | | ------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Battery model (`BatteryModel`) | The commercial product | `name`, `sku`, `gtin`, `category`, `archived_at` | [`listBatteryModels`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-battery-models), [`getBatteryModel`](https://passport.kabin.sh/docs/en/api-reference/catalog/get-battery-model) | | Model version (`ModelVersion`) | One technical state of the model, for example after a cell change | `version_label`, `effective_from`, `status`, `unit_count`, `approved_at`, `registry_version` | [`listModelVersions`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-model-versions), [`getModelVersion`](https://passport.kabin.sh/docs/en/api-reference/catalog/get-model-version) | | Unit (`Battery`) | One physical battery with a serial number | `serial_number`, `model_version_id`, `manufacturing_date`, `facility_id`, `battery_status`, `passport_status` | [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries), [`getBattery`](https://passport.kabin.sh/docs/en/api-reference/catalog/get-battery), [`exportUnits`](https://passport.kabin.sh/docs/en/api-reference/catalog/export-units) | The `category` of a model (`ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`) decides which fields the registry requires for its batteries. ## Model version status A model version has a start date, `effective_from`, and a status: | `status` | Meaning | | ------------ | ----------------------------------------------------------------------------------------------------------------------- | | `draft` | In preparation. Units on a draft version cannot be published. | | `approved` | The current technical state. A model has at most one approved version; a reviewer approves it in the portal. | | `superseded` | Replaced by a later approved version. It stays readable and keeps its units; their published passports are not changed. | A version has no end date: it applies from its `effective_from` until a later version takes over. When a unit is created in the portal or by the spreadsheet import without a named version, BatteryPass assigns the version in force on the unit's manufacturing date: the one with the latest `effective_from` on or before that date, preferring approved and superseded versions over drafts. Approving a new version moves no existing unit. Members move units to another version in the portal after a change-impact preview, and a published passport of a moved unit becomes `update_needed` ([Passports](https://passport.kabin.sh/docs/en/concepts/passports)). ## Which values live where The [field registry](https://passport.kabin.sh/docs/en/general/glossary) assigns every field a level. Read it with [`getRegistrySchema`](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-schema): each field has `level` set to `model` or `unit`. | Level | Typical fields | Values | Completeness | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Model version | Manufacturer, mass, warranty, chemistry and composition, hazardous substances, carbon footprint, due diligence, circularity, performance ratings, labels and the declaration of conformity, e.g. `performance.nominal_voltage`, `materials.battery_chemistry` | [`listModelVersionValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-model-version-values) | [`getModelVersionCompleteness`](https://passport.kabin.sh/docs/en/api-reference/values/get-model-version-completeness) | | Unit | Serial number, manufacturing date and place, battery status, date of putting into service, and the condition data, e.g. `identification.serial_number`, `condition.capacity_fade` | [`listBatteryValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-values) | [`getBatteryCompleteness`](https://passport.kabin.sh/docs/en/api-reference/values/get-battery-completeness) | A few registry fields are attributes of the model or unit itself rather than separate values: the product name, SKU, GTIN and category of the model; the serial number, manufacturing date, facility, battery status and date of putting into service of the unit. You read them on `BatteryModel` and `Battery`. > [!NOTE] > A unit's completeness response also carries the summary of its model version (`model_version`), so one call > tells you whether either level still blocks publication. ## Identifiers | Identifier | Where | Rules | | ------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | GTIN | On the battery model | Accepted with 8, 12, 13 or 14 digits and stored as GTIN-14 with a valid check digit | | Serial number | On the unit | Letters, digits, `-`, `/` and `.`, at most 50 characters, unique per model | | GTIN and serial number together | The key of the unit | Unique across all organisations; the [GS1 Digital Link](https://passport.kabin.sh/docs/en/concepts/digital-link) encodes exactly this pair | Once the identifiers are locked (for label printing before publication, or at the first publication), `identifiers_locked_at` is set and the serial number and the model's GTIN can no longer change. Second life: a repurposed, reused or remanufactured battery is a new unit. It names the original in `predecessor_battery_id` (the original lists it in `successor_battery_ids`) and gets its own passport. ## Reading the catalogue List the versions of a model, then the units of one version: ```bash title="cURL" curl -s "$BATTERYPASS_URL/api/v1/battery-models/$MODEL_ID/versions" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" curl -s "$BATTERYPASS_URL/api/v1/batteries?model_version_id=$VERSION_ID&limit=200" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` `listModelVersions` returns at most 200 versions, newest `effective_from` first, without a cursor. `listBatteries` is ordered by serial number and paged with `limit` and `cursor` ([Pagination](https://passport.kabin.sh/docs/en/pagination)); it also filters by `battery_model_id`, `passport_status`, `serial_prefix` and `import_id`. > [!NOTE] > The API reads the catalogue; it does not create or change it. Members create models, versions and units in > the portal or with the spreadsheet import, and reviewers approve model versions. --- # Passports and versions > The passport of a unit, its statuses, its signed and immutable versions, the differences between them and the exports. Every unit has exactly one passport. Each publication adds a numbered version that never changes afterwards. When the data of a unit changes, the passport is published again and gets a new version; the old versions stay readable as the passport's history. ## The passport Read a unit's passport with [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport). | Field | Meaning | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `passport_id` | The permanent identifier `urn:dpp:{uuid}`, assigned when the identifiers are locked or at the first publication. Public operations take the UUID part as `passportId`. | | `status` | See below. | | `gs1_digital_link` | The link on the QR code ([GS1 Digital Link](https://passport.kabin.sh/docs/en/concepts/digital-link)); fixed once the identifiers are locked. | | `gtin`, `serial_number` | The unit's identifiers as locked. | | `current_version` | Summary of the version that is online now. | | `first_published_at`, `identifiers_locked_at` | When the passport went online for the first time, and when its identifiers were fixed. | | `public_url` | The public passport page, once published. | | `withdrawn_at`, `withdrawn_reason` | Set when the passport was taken offline. | A unit whose identifiers are not locked and that was never published has a passport in status `draft` without `passport_id`. ## Statuses ```mermaid stateDiagram-v2 [*] --> draft draft --> qr_locked: identifiers locked draft --> published: first publication qr_locked --> published: first publication published --> update_needed: data or registry changed update_needed --> published: published again published --> withdrawn: withdrawn with a reason ``` | `status` | Online | Meaning | | ------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `draft` | No | Nothing published yet, no identifier. | | `qr_locked` | No | Identifiers locked so labels can be printed before the first publication. Public reads return the status without content. | | `published` | Yes | The current version is public. | | `update_needed` | Yes | Still online with its current version, but needs to be published again: the registry changed, the unit moved to another model version, or an automatic publication of condition data was blocked. | | `withdrawn` | No | Taken offline by an admin or owner, with a reason. Public reads return the status without content. | | `suspended`, `deleted_recycled` | No | Defined in the contract for later use; the current release does not set them. | ## Versions A version is created by a publication. Reviewers publish in the portal, with a reason; condition data can also publish a version automatically ([Condition data and lifecycle](https://passport.kabin.sh/docs/en/concepts/condition-and-lifecycle)). Publishing unchanged content adds no version. Each version stores, immutably: - the **snapshot**: every field of both levels in every tier, with its value, unit, status and provenance (source, revision, reviewer, review or measurement time, documents); - the **public document**: the `public` tier projection of the snapshot; - a [signature](https://passport.kabin.sh/docs/en/concepts/signatures) of each, with the hashes `canonical_hash` and `public_hash` and the `key_id`; - the BatteryPass v2.0 export and its schema report (advisory: it does not block publication). | Operation | Returns | | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | [`listPassportVersions`](https://passport.kabin.sh/docs/en/api-reference/passports/list-passport-versions) | Version summaries, newest first. With `at`, the first item is the version that was valid at that instant. | | [`getPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version) | One version with `snapshot`, `public_document`, both signatures and the `batterypass_v2` report. | | [`getPublicPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-version) | The public passport as it was at `at`, without a key. | A summary carries `version_no`, `published_at`, `reason`, `published_by_name` (absent when the system published), `registry_version`, the hashes, `key_id` and `valid_until`. `valid_until` is set when a later version replaces this one, so the version valid at time `t` is the one with `published_at ≤ t < valid_until`. ## Differences between versions [`getPassportVersionDiff`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version-diff) compares two signed snapshots field by field. By default it compares a version with the one before it; `against` names another version number. | `fields[].change` | Meaning | | ----------------- | ---------------------------------------------------------- | | `added` | The field has a value in this version and had none before. | | `removed` | The field had a value before and has none now. | | `changed` | The value changed. | | `provenance` | Same value, newer revision: re-approved or measured again. | Each change has `before` and `after` with value, unit, tier, source, revision, `measured_at` and the titles of the documents. `metadata[]` lists changes of `model_version`, `registry_version`, `category`, `facility`, `operator` and `predecessor`. ```bash title="cURL" curl -s "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/versions/3/diff" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` ## Exports | Export | Operation | Content | | ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | BatteryPass JSON | [`exportPassportJson`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-json) | The BatteryPass v2.0 document of a version, all tiers, as stored at publication. Sent as an attachment. | | PDF | [`exportPassportPdf`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-pdf) | Every tier with restricted rows tagged, the signature and the QR code; `lang=de` or `lang=en`. | | QR code | [`getPassportQr`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr) | The GS1 Digital Link as PNG (`size` 128 to 2048 pixels, default 512) or SVG (`format=svg`). Only once the identifiers are locked; `409 conflict` before. | | Public JSON | [`getPublicPassport`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport) | The signed public document of the current version, without a key. | > [!WARNING] > The BatteryPass JSON and the PDF contain the restricted tiers. Treat these files as confidential and do not > publish them; the public JSON is the version meant for everyone ([Access tiers](https://passport.kabin.sh/docs/en/concepts/access-tiers)). ## EU registry [`getRegistrySubmissionStatus`](https://passport.kabin.sh/docs/en/api-reference/passports/get-registry-submission-status) reports the passport's registration with the EU registry. The EU registry offers no interface for economic operators yet, so BatteryPass submits nothing and the state is `not_available`. A passport is never shown as registered without the registry's confirmation. > [!NOTE] > Publishing and locking identifiers are portal actions for reviewers; withdrawing a passport is one for admins > and owners. An API key reads passports and their versions, but never publishes or withdraws. --- # Access tiers > Which passport data is public, which is restricted, and how BatteryPass keeps restricted data out of public responses. The Battery Regulation splits passport data by audience (Art. 77(2), Annex XIII). BatteryPass gives every field of the registry one access tier and filters by tier on the server: a response never contains data above the tier it is meant for. ## The tiers `Tier` names the tier of a field or document; `PassportTier` names the tier a passport document is projected to. A projection contains its tier and the ones before it: `legitimate_interest` includes `public`, `authority` includes both. | Tier | Audience | Examples | | --------------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | `public` | Everyone who scans the QR code | Manufacturer, category, chemistry, capacity, voltages, hazardous substances, declaration of conformity | | `legitimate_interest` | Persons with a legitimate interest, plus notified bodies, market surveillance authorities and the Commission | Detailed composition, dismantling information, battery status, condition data such as capacity fade and cycle counts | | `authority` | Notified bodies, market surveillance authorities and the Commission | Results of test reports | | `internal` | Your organisation only | Never part of a passport projection | The registry decides the tier of each field: [`getRegistrySchema`](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-schema) returns `tier` for every field. In the current registry all condition data fields are `legitimate_interest`. ## The public projection At publication BatteryPass projects the version's snapshot to the `public` tier, stores the result as the version's public document and [signs it](https://passport.kabin.sh/docs/en/concepts/signatures). Public operations serve this stored document; they never project old versions again with newer code or a newer registry. A later registry change can therefore neither reveal nor hide data in a version that is already published. The public document lists fields of the `public` tier only. For the fields above it that have a value, it gives the keys in `restricted_fields_present`, never the values. Documents of a restricted tier are left out entirely, not even with their title. ```bash title="cURL" curl -s "$BATTERYPASS_URL/api/v1/public/passports/$PASSPORT_UUID" \ | jq '{tier: .document.tier, restricted: .document.restricted_fields_present}' ``` ```json title="Response" { "tier": "public", "restricted": ["identification.battery_status", "condition.capacity_fade"] } ``` ## Public operations return the public tier only These operations need no key and only ever return `public` data: | Operation | Returns | | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | | [`getPublicPassport`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport) | The current version's public document | | [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id) | The same, looked up by GTIN and serial number | | [`getPublicPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-version) | The public document valid at a point in time | | [`downloadPublicPassportDocument`](https://passport.kabin.sh/docs/en/api-reference/public/download-public-passport-document) | A `public` document that a public field names; anything else is `404` | | [`verifyPassport`](https://passport.kabin.sh/docs/en/api-reference/public/verify-passport) | The result of the signature check, without data | The [resolver](https://passport.kabin.sh/docs/en/concepts/digital-link) behind the QR code links to the same public page and JSON. ## What an API key reads An API key reads your own organisation's passports with a viewer's rights, and that includes the restricted tiers of your own data: the `snapshot` of [`getPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version), [`exportPassportJson`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-json) and [`exportPassportPdf`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-pdf) carry every tier. One exception: files of the `authority` and `internal` tiers can only be downloaded by editors and above, so [`downloadEvidence`](https://passport.kabin.sh/docs/en/api-reference/evidence/download-evidence) answers `403 forbidden` to a key for them. > [!WARNING] > Data your integration reads with a key is not automatically public. Keep restricted values out of anything you > show to end customers, and publish only what the public JSON contains. ## Restricted tiers for third parties Third parties, such as a repair shop or an authority, do not read restricted tiers through the API. BatteryPass has a separate, portal-based access for them: - It is off unless the operator of the installation enables it (`TIER_PORTAL_ENABLED`, off by default). While it is off, the public JSON carries no `links.restricted` and nothing points to it. - When it is on, an owner or admin of the economic operator grants `legitimate_interest` or `authority` to a reader's email address, with a purpose and an expiry of at most one year, and can revoke the grant at any time. - The reader signs in to the portal with that verified address and sees the current version projected to the granted tier. Restricted documents are listed by title only. - Every restricted read is recorded in the audit log of the passport's organisation. This access is not an API key feature and not part of the published contract. > [!NOTE] > The implementing act under Art. 77(9) that defines who has a legitimate interest, and how they prove it, is > still pending. Until then the economic operator decides whom to grant access; BatteryPass does not present > these grants as the regulation's access mechanism. --- # Provenance and review > Every value keeps its source, author, time and evidence and is superseded rather than overwritten; declared data is reviewed before it enters a passport, with a second person when the organisation's four-eyes rule is on. A passport is only as trustworthy as the values in it. BatteryPass therefore records where each value comes from, who entered and approved it, and which documents support it, and it keeps every earlier state. ## A value is a revision Every value is stored as a revision (`FieldValue`). The API returns these members: | Member | Meaning | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | | `value`, `unit` | The value as JSON and its unit of measure; `value` is `null` when the field was cleared | | `revision` | 1, 2, 3 … per field and target (model version or unit) | | `source` | Where the value came from, see below | | `recorded_by`, `recorded_at` | Who recorded this revision and when | | `responsible` | The member responsible for the value | | `review_status`, `reviewed_by`, `reviewed_at`, `review_note` | The review decision | | `evidence[]` | The documents this revision rests on (kind, title, tier, revision) | | `checks[]` | Results of the automatic checks: `pass`, `warn` or `fail` with a message | | `measured_at` | For condition data: when the value was measured | | `superseded_at` | When a newer revision replaced this one | | `registry_version` | The registry version the value was recorded under | | `source` | Meaning | | ---------- | --------------------------------------------------------------------------- | | `manual` | A member entered it in the portal | | `import` | It came from a spreadsheet import | | `supplier` | A supplier sent it through a request link, or a service partner reported it | | `api` | An API key reported it (condition data only) | | `system` | BatteryPass wrote it | ## Values are superseded, never overwritten A change never edits a stored value. It inserts a new revision and marks the previous one as superseded, so the full history stays available, including who changed what and when. A review decision is a revision too: approving inserts a revision with the same value and the reviewer. | Operation | Returns | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | [`listModelVersionValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-model-version-values), [`listBatteryValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-values) | The current revision of each field of a model version or unit | | [`listModelVersionValueHistory`](https://passport.kabin.sh/docs/en/api-reference/values/list-model-version-value-history), [`listBatteryValueHistory`](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-value-history) | Every revision of one field, newest first | | [`listEvidence`](https://passport.kabin.sh/docs/en/api-reference/evidence/list-evidence), [`getEvidence`](https://passport.kabin.sh/docs/en/api-reference/evidence/get-evidence), [`downloadEvidence`](https://passport.kabin.sh/docs/en/api-reference/evidence/download-evidence) | The documents, their revisions, where they are used, and the files | ```bash title="cURL" curl -s "$BATTERYPASS_URL/api/v1/model-versions/$VERSION_ID/values/performance.nominal_voltage/history" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ | jq '.items[] | {revision, value, source, review_status, reviewed_at}' ``` Documents are versioned in the same way: a new revision of a document supersedes the old one, and a value that rests on a superseded document is flagged by a failing check. ## Review status | `review_status` | Meaning | | --------------- | ---------------------------------------------------------------------- | | `captured` | Recorded; an automatic check failed or has not passed yet | | `auto_checked` | The automatic checks passed; waiting for a reviewer | | `approved` | A reviewer approved it; only approved values enter a passport version | | `rejected` | A reviewer rejected it with a note; the responsible member corrects it | A reviewer (role `reviewer`, `admin` or `owner`) approves in the portal. An approval is refused while - a check of the value fails, - the registry requires evidence for the field and no current document supports the value, or - the reviewer recorded the value themselves and the organisation has the four-eyes rule on (the default). The values of a spreadsheet import can be approved in one action per import, under the same rules for each value. Values that one of these rules holds back stay open for individual review. > [!NOTE] > Condition data is the exception: values reported as measurements are accepted without a reviewer and stored > as `approved` with their `measured_at`. See [Condition data and lifecycle](https://passport.kabin.sh/docs/en/concepts/condition-and-lifecycle). ## Why API keys cannot approve or publish An API key belongs to the organisation, not to a person. The four-eyes rule compares the author of a value with its reviewer, and publication needs a reviewer who answers for it. A key that is nobody could not take part in either without weakening them, so keys never write declared data, approve values, publish or withdraw passports. They read, and with the `condition` scope they report measurements and lifecycle events, which carry `source` `api`; the lifecycle event of each report names the key in `api_key`. Declared data reaches BatteryPass through the portal, supplier requests or the spreadsheet import, and is approved by members. With the `import` scope a key stages a spreadsheet import: upload, mapping, validation. Only members commit it and approve the imported values in the portal, so data from an ERP becomes declared data only through a member's decision (ADR-026). See [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). ## Completeness [`getModelVersionCompleteness`](https://passport.kabin.sh/docs/en/api-reference/values/get-model-version-completeness) and [`getBatteryCompleteness`](https://passport.kabin.sh/docs/en/api-reference/values/get-battery-completeness) evaluate the registry for a model version or unit and give each field a `display` status: | `display` | Meaning | | ------------------------- | ----------------------------------------------------------- | | `required_missing` | Required now, no current approved value; blocks publication | | `required_pending_review` | A value exists but is not approved; blocks publication | | `ok` | An approved value is present | | `not_applicable` | Marked as not applicable, with a stored reason | | `later_required` | The obligation starts later or is not triggered yet | | `optional_missing` | Optional and empty | There is deliberately no percentage. The `summary` counts the statuses, and `summary.blocking` is the number of fields that still block publication. --- # Condition data and lifecycle events > How measured condition data reaches a passport without a review step, how the re-publication policy works, and what lifecycle events record. A battery's passport does not stop at the factory. Two kinds of information follow it through its life: **condition data**, the measured state of health and usage of a unit, and **lifecycle events**, the record of what happened to it. Both can be reported through the API with a key that has the `condition` scope. ## Condition data Condition data are the registry's dynamic fields (the section `condition`, Annex XIII point 4): measured values of one unit, such as `condition.capacity_fade`, `condition.remaining_capacity`, `condition.number_of_full_cycles` or, for EV batteries, `condition.state_of_certified_energy`. Which fields apply depends on the battery category; [`getCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/get-condition) lists them in `field_keys`, with their current values and `measured_at`. In the current registry all condition fields are in the `legitimate_interest` tier, so they never appear in the public JSON ([Access tiers](https://passport.kabin.sh/docs/en/concepts/access-tiers)). ### Measurements, not declarations What a battery management system measured is not something a reviewer can verify better than the device. A condition update is therefore accepted as a measurement, without the review step that declared data needs: - each value is stored directly as `approved`, with its `measured_at`, its `source` (`api` for a key, `supplier` for a service partner's report, `manual` for a member); a member is named in `recorded_by`, a key in the `api_key` of the update's lifecycle event (values carry no key); - `reviewed_at` is the time of acceptance, and there is no reviewer, so the four-eyes rule does not apply; - every accepted update records a `condition_update` lifecycle event and an audit entry. Condition values that a member types into the unit editor or imports from a spreadsheet keep the normal review ([Provenance and review](https://passport.kabin.sh/docs/en/concepts/provenance-and-review)). ### Reporting an update [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition) merges the values you send into the unit's condition data: ```bash title="cURL" curl -s -X PATCH "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/condition" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "measured_at": "2026-10-24T06:00:00Z", "note": "Weekly BMS reading", "values": [ { "field_key": "condition.capacity_fade", "value": 3.2 }, { "field_key": "condition.number_of_full_cycles", "value": 412 } ] }' ``` | Rule | Effect | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Merge | Only the named fields change; `null` clears a field. At most 50 values per request. | | `measured_at` | Each value has its own or the request's. It must not lie in the future or before the unit's manufacturing date. | | Older or equal measurement | A value measured before the stored one, or at the same time with the same value, is skipped and listed in `skipped[]` (`older_measurement`, `unchanged`). | | All or nothing | A field that is not a dynamic field of the unit (`unknown_field`), an invalid value, or merged data that fails a cross-field check refuses the whole update. | The response lists `accepted` values, `skipped` ones, the recorded `event` and the `publication` outcome. ### Re-publication policy When the unit's passport is published, accepted condition data has to reach a new passport version. The organisation chooses when, in its settings in the portal (`condition_republish`, returned by `getCondition` as `republish`): | Policy | Behaviour | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `immediate` (default) | The same request publishes the next version, with the reason `condition update` and no member as publisher. | | `daily` | A daily job at 02:00 UTC publishes every published passport whose unit received condition data after its current version, so a day's updates become one version. | `publication.outcome` in the response tells you what happened: | `outcome` | Meaning | | --------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `published` | A new version was published in this request. | | `unchanged` | Nothing in the passport changed, so no version was added. | | `scheduled` | The daily job will publish it. | | `blocked` | The publish gate refused (see `blockers`); the update stays accepted and the passport becomes `update_needed`, still online. | | `not_published` | The unit has no published passport yet. | ## Lifecycle events A lifecycle event records something that happened to a unit on a given day. Report one with [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event) and read the history, newest first, with [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events). | Type you can report (`ReportedEventType`) | Typical use | | ----------------------------------------- | ---------------------------------------------- | | `placed_on_market` | The battery was placed on the market | | `put_into_service` | The battery was put into service | | `inspection` | An inspection or diagnosis | | `repair` | A repair | | `component_replaced` | A module, cell or other component was replaced | | `condition_update` | A condition reading worth recording on its own | | `waste` | The battery became waste | | `recycled` | The battery was recycled | | `custom` | Anything else, described in `description` | BatteryPass records two more kinds itself: `condition_update` for every accepted condition update, and `repurposed`, `reused` or `remanufactured` when a member creates a second-life battery in the portal. An event has `occurred_on` (a date between the manufacturing date and today), an optional `description` (up to 2000 characters) and up to 20 `evidence_ids`, documents that already exist in the evidence library such as a repair protocol. Events reported with a key carry `source` `api` and name the key in `api_key`. > [!WARNING] > Events are records: they change no data of the unit. A `put_into_service` event does not set the unit's > `putting_into_service` date, and `waste` or `recycled` does not change its battery status or end its passport. > Report measured values with `updateCondition`; members change unit data in the portal. Events cannot be edited > or deleted afterwards. ## Next steps [**Push condition data**](https://passport.kabin.sh/docs/en/guides/push-condition-data): Report measurements from a battery management system. [**Record lifecycle events**](https://passport.kabin.sh/docs/en/guides/record-lifecycle-events): Report repairs, inspections and other events. --- # GS1 Digital Link and resolver > The link on every QR code, and how the resolver sends browsers to the public page and machines to the public JSON. Every passport has one web address that identifies the battery: a GS1 Digital Link. It is what the QR code on the battery carries. The same address serves people and machines: the resolver behind it decides what to return from the request. ## The link ```text https://batterypass.example.com/01/04012345000023/21/SV2027-000184 ``` | Part | Meaning | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `https://batterypass.example.com` | The resolver's origin: your BatteryPass address, or a separate resolver hostname your operator configured | | `/01/04012345000023` | GS1 application identifier `01`: the model's GTIN, always with 14 digits | | `/21/SV2027-000184` | GS1 application identifier `21`: the unit's serial number; a `/` in a serial number is sent percent-encoded (`%2F`) | The link is in `gs1_digital_link` of [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport) and is fixed once the identifiers are locked. [`getPassportQr`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr) renders exactly this link as PNG or SVG (error correction level M, a quiet zone of four modules). Always take the link from the API rather than building it yourself. ## What the resolver returns The resolver lives at the root of the origin (`/01/…/21/…`), not under `/api/v1`, and needs no key. | Request | Answer | | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | A browser (`Accept: text/html`, or no JSON preference) | `307` to the public passport page `/pass/{passportId}` | | `?linkType=describedby` (or `gs1:describedby`) | `307` to the public JSON `/api/v1/public/passports/{passportId}` | | `?linkType=all`, or `Accept: application/json`, `application/linkset+json` or `application/ld+json` without `linkType` | `200` with a linkset (`application/linkset+json`) | | An unknown GTIN and serial number | `404`: a short page for browsers, a Problem `not_found` for JSON clients | | Too many requests from one address | `429 rate_limited` with `Retry-After` | Every answer for a known passport also carries the links in a `Link` header, with the relations `https://gs1.org/voc/pip` (the page), `describedby` (the public JSON) and `linkset`. Answers are cacheable for five minutes and vary by `Accept`. A passport whose identifiers are locked but that is not published yet, or one that was withdrawn, still resolves: its page explains the state, and the public JSON returns the status without content. ## Following the link from code ```bash title="cURL" # The public JSON of the passport behind a QR code curl -sL "https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby" # The linkset curl -s -H "Accept: application/linkset+json" \ "https://batterypass.example.com/01/04012345000023/21/SV2027-000184" ``` The linkset names the page and the public JSON: ```json title="Linkset" { "linkset": [ { "anchor": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184", "https://gs1.org/voc/pip": [ { "href": "https://batterypass.example.com/pass/0192f0c4-7b1e-7c3a-9d2e-5a8b6c4d3e21", "type": "text/html", "title": "Battery passport" } ], "describedby": [ { "href": "https://batterypass.example.com/api/v1/public/passports/0192f0c4-7b1e-7c3a-9d2e-5a8b6c4d3e21", "type": "application/json", "title": "Public passport (JSON)" } ] } ] } ``` If you already know GTIN and serial number, you can skip the resolver and call [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id) with `{gtin}:{serial}`, for example `04012345000023:SV2027-000184`. The GTIN may have 8, 12, 13 or 14 digits there. > [!NOTE] > The resolver, the public passport reads and the public documents share one limit of 300 requests per minute per > client address. A scanner app that resolves many codes should respect the cache headers; public passport > reads may be cached for five minutes. ## Related pages [**Resolve a Digital Link**](https://passport.kabin.sh/docs/en/guides/resolve-digital-link): Turn a scanned code into the public passport data. [**QR labels for a batch**](https://passport.kabin.sh/docs/en/recipes/qr-labels-for-a-batch): Fetch the QR codes for the units of a batch. --- # Signatures > What BatteryPass signs in each passport version, how the hash and the Ed25519 signature are computed, and how to check them with the published keys. Every published passport version is signed, so anyone can check that the data comes from your BatteryPass installation and was not changed afterwards. The check needs no account and no call to BatteryPass: the public JSON and the published keys are enough. ## What is signed At publication BatteryPass signs two documents of each version with the active key, in the same transaction that stores them: | Document | Contains | Hash and signature | Where to read it | | --------------- | ------------------------------------------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Public document | The `public` tier projection, with labels | `public_hash`, `public_signature` | `document` and `verification` of the public JSON ([`getPublicPassport`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport)); `public_document` of [`getPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version) | | Snapshot | Every field in every tier, with provenance and document references | `canonical_hash`, `signature` | `snapshot` of [`getPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version), for your organisation | The public document is what a scanner sees, so anyone can verify it. The snapshot proves the full record to you and to authorities you show it to. In the public JSON only `document` is signed; its siblings `passport_id`, `status`, `valid_until`, `verification` and `links` change over the passport's life and are not covered. ```json title="verification" { "algorithm": "Ed25519", "canonicalization": "RFC8785", "hash": "sha256:22471733ece6d811d244144731fc505180c5e7760a7a016c1322338bce0f56e5", "signature": "w0nnz3kjhrEGb1DtMoGp9qlH9g8q0h9YDJRb2jfabq7AOzPMUpnWXoxmIlptgJPTDVy3LWxLQkOpQYuGYXgJ4w", "key_id": "2027-10-k2", "signed_at": "2026-10-24T09:15:02Z", "keys_url": "https://batterypass.example.com/.well-known/batterypass/keys.json" } ``` ## How a signature is made and checked 1. **Canonicalise** Serialise the signed object (`document`, or `snapshot`) with the JSON Canonicalization Scheme, JCS (RFC 8785). JCS sorts object members, drops whitespace and writes numbers and strings in one defined form, so the same data always gives the same bytes. Canonicalise the parsed object, not the response bytes. 2. **Hash** Compute SHA-256 over the canonical bytes and write it as `sha256:` followed by the lowercase hex digest. It must equal `verification.hash` (or `public_hash`, `canonical_hash`). 3. **Find the key** Fetch the JWK set from `/.well-known/batterypass/keys.json` of the BatteryPass address you trust and take the key whose `kid` equals `verification.key_id`. `keys_url` is not signed: if it points to another address, treat the check as failed. 4. **Verify** Decode the key's `x` and the signature from base64url without padding (32 and 64 bytes) and check the Ed25519 signature over the canonical bytes, not over the hash. The [signature verification guide](https://passport.kabin.sh/docs/en/guides/verify-signature) does this step by step with complete code. ## The published keys The public keys are a JWK set at `/.well-known/batterypass/keys.json` on your BatteryPass address, outside `/api/v1`. It needs no key, may be read from any origin (CORS) and is cacheable for an hour. ```json title="keys.json" { "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "WuoaQRoQuh2kPs5yYB6HxNz3Y58ndE8fRHnzG10NxoU", "kid": "2027-10-k2", "use": "sig", "alg": "EdDSA" }, { "kty": "OKP", "crv": "Ed25519", "x": "JEpD6r2NnmNrL4_7UWS9wIWLFyjCfaSClypHm110Vl4", "kid": "2026-10-k1", "use": "sig", "alg": "EdDSA" } ] } ``` The active key is listed first. The operator of your installation rotates keys: a new key signs new versions, and the old public keys stay in the set so earlier signatures keep verifying. A key that signed a published version is never removed. Cache keys by `kid` and fetch the set again when you meet an unknown `kid`. ## Verifying on the server or offline | | [`verifyPassport`](https://passport.kabin.sh/docs/en/api-reference/public/verify-passport) | Offline verification | | -------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | What it checks | Both signatures and both hashes of a version against the stored documents | The public document you hold, against the published key | | Result | `valid`, `public_document_valid`, `snapshot_valid`, `key_id`, `version_no`, `signed_at`, `checked_at` | Your own yes or no | | Trust | You trust the server's answer | You trust only the key, which you can pin | | Needs | The passport UUID; `version_no` optional (default: current version) | The public JSON and the JWK set | `verifyPassport` is a convenience for a quick check. For an independent proof, for example in an archive or a customer's system, verify offline. > [!NOTE] > A valid signature shows that this BatteryPass installation published exactly this data in this version. It > does not say that the data is correct or complete, and it is not a qualified electronic seal under eIDAS. --- # Security and data protection > How BatteryPass protects API access and passport data, and what your integration should do and avoid. This page describes the technical measures that apply to integrations and what you are responsible for on your side. It is a description, not a certification or an audit statement. ## Transport Production installations of BatteryPass are served over HTTPS; the reference deployment terminates TLS at its reverse proxy and sends `Strict-Transport-Security`. Send API keys only to your BatteryPass address over HTTPS, and do not disable certificate checks in your HTTP client. ## API keys | Measure | Detail | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Secret | `bp_live_` followed by 43 base64url characters (32 random bytes) | | Storage | BatteryPass stores only the SHA-256 of the key. The key is shown once, when an owner creates it in the portal; it cannot be displayed again. | | Recognition | The first 12 characters (`bp_live_` and four more) are kept as a prefix, so owners can tell keys apart in the portal. | | Header only | Keys are accepted only in `Authorization: Bearer`, never in a URL. A request that carries both a session cookie and a key is refused with `401`. | | Lifetime | Keys have no expiry. Revoking is the only way to stop one; a revoked key never works again. Rotate by creating a new key and revoking the old one. | | Number | At most 25 active keys per organisation. | Owners create and revoke keys in the portal and see when each key was last used. See [Authentication](https://passport.kabin.sh/docs/en/authentication) and [Secure key handling](https://passport.kabin.sh/docs/en/guides/secure-key-handling). ## What a key may do - **Scopes.** A key has one or more of `read`, `condition` and `import`. Each operation names its scope in `x-api-key-scope`; a key without that scope gets `403 forbidden`. Give each system only the scope it needs. - **Viewer rights.** A key acts in its organisation with the rights of a viewer. It never changes declared data, approves values, publishes or withdraws passports, and never manages members, keys or settings. - **Organisation isolation.** Every request is scoped to the key's organisation. Ids of another organisation's resources behave as if they did not exist (`404 not_found`). ## Rate limits | Limit | Applies to | | -------------------------- | ---------------------------------------------------------------------------- | | 600 requests per minute | All keys of one organisation together | | 30 attempts per 15 minutes | Unknown or revoked keys, per client address | | 300 requests per minute | Public passport reads, public documents and the resolver, per client address | A limited request gets `429 rate_limited` with a `Retry-After` header in seconds ([Rate limits](https://passport.kabin.sh/docs/en/rate-limits)). ## Audit Every write made with a key (a condition update or a lifecycle event) is recorded in the organisation's audit log with the actor `api_key` and the key's id, in the same transaction as the change. The values and events it creates carry `source` `api`; the lifecycle event of each update names the key (`api_key`). Creating and revoking keys is audited with the member who did it. Reads with a key are not audited. ## Passport data - Public operations and the resolver serve only the stored `public` projection of a version ([Access tiers](https://passport.kabin.sh/docs/en/concepts/access-tiers)). Restricted tiers never appear in a public response, and restricted documents are never linked from one. - Reading restricted tiers as a third party is a separate portal feature behind a switch that is off by default; every such read is audited. - Each published version is signed, so changes after publication are detectable ([Signatures](https://passport.kabin.sh/docs/en/concepts/signatures)). - Errors are Problem Details with a stable `code` and a `request_id`; they contain no SQL, stack traces or upstream error texts ([Errors](https://passport.kabin.sh/docs/en/errors)). ## Data protection for integrations BatteryPass is about batteries, not people, and your integration should keep it that way. > [!WARNING] > Do not send personal data in free text: the `note` of a condition update and the `description` of a lifecycle > event. Write "Module 3 replaced" rather than the technician's or the end customer's name, address, phone number > or vehicle registration. Events and measurements cannot be edited or deleted through the API, every member of > your organisation can read them, and they are part of the organisation's full export. What you read can contain personal data too: provenance names members (`recorded_by`, `reviewed_by`, `responsible`, `published_by_name`), and an event reported by a service partner can carry the partner's email address. If your integration copies such data into another system, that system needs a legal basis and a retention rule for it. Copy only what you need. ## This documentation The documentation pages load no analytics, advertising or third-party tracking scripts. The menu entries that open a page in an AI assistant send the page's address to that service only when you click them. ## Reporting a security problem If you suspect a key has leaked, ask an owner of your organisation to revoke it at once and create a new one. Report a suspected vulnerability directly to the operator of your BatteryPass installation, not in a shared channel ([Support](https://passport.kabin.sh/docs/en/general/support)). --- # Support > How to get help with an integration, what to include in a request, and where the status of features is documented. ## Who to contact | Question | Contact | | ------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | API keys, scopes, organisation settings such as the re-publication policy | An owner of your organisation, in the portal | | Data in a passport, review, publication | The members of your organisation responsible for the data | | Errors you cannot explain, outages, suspected security problems | Your BatteryPass contact, or the operator of your BatteryPass installation | Support channels and response times are agreed with the operator of your installation; this documentation does not define them. ## Check first - The Problem's `code`, `detail` and `errors[]` usually name the cause; [Errors](https://passport.kabin.sh/docs/en/errors) explains every code, and [Handle errors](https://passport.kabin.sh/docs/en/guides/handle-errors) shows how to react to them. - `429 rate_limited`: wait for `Retry-After` seconds ([Rate limits](https://passport.kabin.sh/docs/en/rate-limits)). - `403 forbidden` with a key: the key lacks the operation's scope, or the operation needs more than a viewer's rights ([Authentication](https://passport.kabin.sh/docs/en/authentication)). ## Quote the request id Every response, successful or not, carries an `X-Request-Id` header. Error responses repeat it as `request_id` in the Problem body. With this id the operator finds your request in the logs. BatteryPass ignores an `X-Request-Id` you send and always assigns its own, so log the one from the response. ```bash title="cURL" curl -s -D - -o /dev/null "$BATTERYPASS_URL/api/v1/battery-models?limit=1" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" | grep -i '^x-request-id' ``` ```ts title="TypeScript" const base = process.env.BATTERYPASS_URL; const key = process.env.BATTERYPASS_API_KEY; const res = await fetch(`${base}/api/v1/battery-models?limit=1`, { headers: { Authorization: `Bearer ${key}` }, }); const requestId = res.headers.get('X-Request-Id'); if (res.ok) { console.log(`ok request_id=${requestId}`); } else { const problem = await res.json(); console.error(`${res.status} ${problem.code} request_id=${problem.request_id ?? requestId}`); } ``` ```python title="Python" import os import requests base = os.environ["BATTERYPASS_URL"] key = os.environ["BATTERYPASS_API_KEY"] res = requests.get( f"{base}/api/v1/battery-models", params={"limit": 1}, headers={"Authorization": f"Bearer {key}"}, timeout=30, ) request_id = res.headers.get("X-Request-Id") if res.ok: print(f"ok request_id={request_id}") else: problem = res.json() print(f"{res.status_code} {problem.get('code')} request_id={problem.get('request_id', request_id)}") ``` ```go title="Go" package main import ( "encoding/json" "fmt" "log" "net/http" "os" ) func main() { req, err := http.NewRequest(http.MethodGet, os.Getenv("BATTERYPASS_URL")+"/api/v1/battery-models?limit=1", nil) if err != nil { log.Fatal(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() requestID := res.Header.Get("X-Request-Id") if res.StatusCode >= 400 { var problem struct { Code string `json:"code"` } _ = json.NewDecoder(res.Body).Decode(&problem) log.Printf("%d %s request_id=%s", res.StatusCode, problem.Code, requestID) return } fmt.Println("ok request_id=" + requestID) } ``` ## What to include 1. **Identify the request** The `request_id`, the time in UTC, the operation (for example `updateCondition`, or method and path) and the HTTP status with the Problem's `code`, `detail` and `errors[]`. 2. **Identify the key and the data** The key's prefix (its first 12 characters, as the portal shows it) and the ids involved: battery id, passport UUID, version number. 3. **Describe the case** What you expected, what happened, and whether it happens every time or only sometimes. > [!WARNING] > Never send the full API key, and leave out personal data and exports that contain restricted tiers (the > BatteryPass JSON and the PDF of a passport version). The request id and the ids are enough to find the request. ## Status of features - The [changelog](https://passport.kabin.sh/docs/en/changelog) lists what changed in the API and in this documentation, also as an [RSS feed](https://passport.kabin.sh/docs/en/changelog/rss.xml). - The published contract states its version in `info.version`; download it as [JSON](https://passport.kabin.sh/docs/openapi.json) or [YAML](https://passport.kabin.sh/docs/openapi.yaml). - [`getRegistryInfo`](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-info) and [`getRegistryStatus`](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-status) report the field registry in use and how many passports need an update after a registry change. - [`getRegistrySubmissionStatus`](https://passport.kabin.sh/docs/en/api-reference/passports/get-registry-submission-status) reports the EU registry state of a passport; it is `not_available` while the EU registry has no interface for economic operators. --- # Glossary > The domain and API terms used in this documentation, in English and German. The German column gives the term used on the German pages of this documentation. Code, field names and operation ids are the same in both languages. ## Products and passports | Term | German | Meaning | | ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Battery passport | Batteriepass | The digital record of one individual battery under Regulation (EU) 2023/1542 (Art. 77). In BatteryPass every unit has one passport with numbered, signed versions. See [Passports](https://passport.kabin.sh/docs/en/concepts/passports). | | Passport version | Passversion | One publication of a passport. It never changes and holds the snapshot, the public document and their signatures. | | Economic operator | Wirtschaftsakteur | The manufacturer, importer or authorised representative that places a battery on the market and is responsible for its passport. | | Battery model | Batteriemodell | The commercial product with name, SKU, GTIN and category. See [Battery models](https://passport.kabin.sh/docs/en/concepts/battery-models). | | Model version | Modellversion | One technical state of a battery model, valid from its `effective_from` date. Model-level values are stored here. Status `draft`, `approved` or `superseded`. | | Unit | Einheit | One physical battery with a serial number, assigned to a model version. Called `Battery` in the API. | | GTIN | GTIN | Global Trade Item Number, the GS1 number of the battery model. BatteryPass stores it with 14 digits. | | GS1 Digital Link | GS1 Digital Link | A web address built from GS1 identifiers, here `/01/{gtin}/21/{serial}`. It is what the QR code carries. See [GS1 Digital Link](https://passport.kabin.sh/docs/en/concepts/digital-link). | | Resolver | Resolver | The service behind the Digital Link: it sends browsers to the public page and machines to the public JSON or a linkset. | | Second life | Second Life | A battery that is repurposed, reused or remanufactured. It becomes a new unit with its own passport that links to the original. | ## Data and review | Term | German | Meaning | | ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Field registry | Feldregister | The versioned list of all passport fields: level, tier, requirement per battery category, validation and export paths. Read it with [`getRegistrySchema`](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-schema). | | Tier (access tier) | Zugriffsstufe | Who may see a field or document: `public`, `legitimate_interest`, `authority` or `internal`. See [Access tiers](https://passport.kabin.sh/docs/en/concepts/access-tiers). | | Provenance | Herkunft | Where a value comes from: source, author, time, reviewer and evidence, kept for every revision. See [Provenance and review](https://passport.kabin.sh/docs/en/concepts/provenance-and-review). | | Evidence | Nachweis | A document that supports a value, such as a declaration of conformity or a test report. Evidence is versioned and has its own tier. | | Four-eyes principle | Vier-Augen-Prinzip | The rule that a value is approved by a different member than the one who recorded it. On by default for every organisation. | ## Condition and lifecycle | Term | German | Meaning | | --------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Condition data | Zustandsdaten | Measured values of one unit, the registry's `condition` section. Accepted as measurements, without a review step. See [Condition data and lifecycle](https://passport.kabin.sh/docs/en/concepts/condition-and-lifecycle). | | State of health | Gesundheitszustand (State of Health, SoH) | How much of its original capability a battery still has. The registry expresses it through fields such as capacity fade, remaining capacity and, for EV batteries, the state of certified energy. | | Lifecycle event | Lebenszyklus-Ereignis | A dated record of something that happened to a unit, such as a repair or an inspection. Events change no data of the unit. | ## API | Term | German | Meaning | | ----------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | API key | API-Schlüssel | An organisation's credential `bp_live_…`, sent as `Authorization: Bearer`. It acts with a viewer's rights. See [Authentication](https://passport.kabin.sh/docs/en/authentication). | | Scope | Scope | What a key may do: `read` (read data), `condition` (report condition data and lifecycle events) or `import` (stage spreadsheet imports that members commit). | | Problem Details | Problem Details | The error format of RFC 9457 (`application/problem+json`), here with a stable `code` and a `request_id`. See [Errors](https://passport.kabin.sh/docs/en/errors). | | Keyset pagination | Keyset-Paginierung | Paging with `limit` and an opaque `cursor`; a page returns `items` and `next_cursor`, which is `null` on the last page. See [Pagination](https://passport.kabin.sh/docs/en/pagination). | ## Signatures | Term | German | Meaning | | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | JCS | JCS | JSON Canonicalization Scheme (RFC 8785): one fixed way to serialise JSON, so the same data always gives the same bytes to hash and sign. | | Ed25519 | Ed25519 | The signature algorithm BatteryPass uses (EdDSA on Curve25519, RFC 8032). Public keys have 32 bytes, signatures 64 bytes. | | JWK | JWK | JSON Web Key (RFC 7517), a public key as JSON. BatteryPass publishes its Ed25519 keys as `OKP` keys (RFC 8037) in a JWK set at `/.well-known/batterypass/keys.json`. See [Signatures](https://passport.kabin.sh/docs/en/concepts/signatures). | --- # Guides > Task-oriented walkthroughs for the jobs an integration does most, with requests, responses and the errors that matter. Each guide takes one task from start to finish: what you need, the requests in order, what comes back and which errors to expect. The samples use the environment variables `BATTERYPASS_URL` and `BATTERYPASS_API_KEY` from the [Quickstart](https://passport.kabin.sh/docs/en/quickstart). ## Public passports [**Verify a signature offline**](https://passport.kabin.sh/docs/en/guides/verify-signature): Canonicalise the public document, compare the hash and check the Ed25519 signature with the published key. [**Resolve a QR code**](https://passport.kabin.sh/docs/en/guides/resolve-digital-link): From a scanned GS1 Digital Link to the public passport JSON, or look it up by GTIN and serial number. ## Reading data [**Sync units**](https://passport.kabin.sh/docs/en/guides/sync-units): Copy units into your system with cursor paging, resume after an interruption and spot changes. [**Export passports**](https://passport.kabin.sh/docs/en/guides/export-passports): BatteryPass JSON, PDF and QR code of a passport version, and the units as a spreadsheet. ## Reporting data [**Push condition data**](https://passport.kabin.sh/docs/en/guides/push-condition-data): Send measurements from a battery management system with `updateCondition`. [**Record lifecycle events**](https://passport.kabin.sh/docs/en/guides/record-lifecycle-events): Report inspections, repairs and other events of a unit, and read its lifecycle. ## Running in production [**Handle errors and retries**](https://passport.kabin.sh/docs/en/guides/handle-errors): Which answers to retry, backoff with jitter, and what to log. [**Handle keys securely**](https://passport.kabin.sh/docs/en/guides/secure-key-handling): Where keys live, which scope each system gets, rotation without downtime and what to do after a leak. Looking for a script to copy? The [Recipes](https://passport.kabin.sh/docs/en/recipes) are short, runnable programs for common jobs. --- # Verify a passport signature offline > Check with the public JSON and the published keys alone that the public data of a passport is exactly what its BatteryPass instance signed. Every published passport version carries an Ed25519 signature over its public document. You can check it without an API key and without asking BatteryPass whether the data is genuine: the public JSON and the key set at `/.well-known/batterypass/keys.json` are all you need. The background is in [Signatures](https://passport.kabin.sh/docs/en/concepts/signatures). ## What you need - The passport: its id (the UUID of `urn:dpp:{uuid}`) or the GS1 Digital Link from its QR code. - The BatteryPass address that published it, as `BATTERYPASS_URL`. Public reads need no key. - An RFC 8785 (JCS) implementation and Ed25519 for your language: | Language | JSON canonicalisation (RFC 8785) | Ed25519 | | -------------------- | ------------------------------------------------------------ | -------------------------------------------------------- | | TypeScript, Node 20+ | [`canonicalize`](https://www.npmjs.com/package/canonicalize) | `node:crypto` | | Python 3 | [`rfc8785`](https://pypi.org/project/rfc8785/) | [`cryptography`](https://pypi.org/project/cryptography/) | | Go | [`github.com/gowebpki/jcs`](https://github.com/gowebpki/jcs) | `crypto/ed25519` | ```mermaid flowchart LR J["Public JSON"] --> D["document"] J --> V["verification"] D -->|"RFC 8785"| C["Canonical bytes"] C -->|"SHA-256"| H{"Equals verification.hash?"} K["keys.json"] -->|"kid = verification.key_id"| P["Ed25519 public key"] C --> S{"verification.signature valid?"} P --> S ``` ## Step by step 1. **Fetch the public JSON** Read the passport with [`getPublicPassport`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport), or follow the Digital Link with `?linkType=describedby`: the resolver answers `307` with the public JSON as the target. [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id) returns the same document by GTIN and serial number. ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10" # or from the QR code (-L follows the redirect) curl -sSL "https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby" ``` ```json title="200" { "passport_id": "urn:dpp:0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "status": "published", "document": { "passport_id": "urn:dpp:0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "gtin": "04012345000023", "serial_number": "SV2027-000184", "gs1_digital_link": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184", "model_name": "SolarVolt Home 10", "tier": "public", "version": { "no": 3, "published_at": "2026-10-20T09:12:44Z", "registry_version": "0.5.0" }, "category": { "key": "stationary", "label": { "de": "Stationärer Energiespeicher (> 2 kWh)", "en": "Stationary storage battery (> 2 kWh)" } }, "sections": [ { "key": "performance", "label": { "de": "Leistung & Haltbarkeit", "en": "Performance & durability" }, "fields": [ { "key": "performance.nominal_voltage", "label": { "de": "Nennspannung", "en": "Nominal voltage" }, "tier": "public", "level": "model", "status": "provided", "value": 51.2, "unit": "V" } ] } ], "restricted_fields_present": ["condition.state_of_charge"] }, "verification": { "algorithm": "Ed25519", "canonicalization": "RFC8785", "hash": "sha256:9c1f0e5b7d4a3c2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e", "signature": "q2v0Yb3m8yJ1kQe7Zt5nC4xL9pW2sR6uH0aF3dG8jK1oM5iT7vX4zB2cN6eQ9rS1wY3uA5gD8hJ0lP4fK7mV2Q", "key_id": "2026-10-k1", "signed_at": "2026-10-20T09:12:44Z", "keys_url": "https://batterypass.example.com/.well-known/batterypass/keys.json" }, "links": { "page": "https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "json": "https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "keys": "https://batterypass.example.com/.well-known/batterypass/keys.json", "gs1_digital_link": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184" } } ``` Only `document` is signed. `passport_id`, `status`, `valid_until`, `verification` and `links` change over the passport's life and are not covered by the signature. A passport that is not public (`qr_locked`, `suspended`, `withdrawn`, `deleted_recycled`) answers with its status and without `document` and `verification`: there is nothing to verify. 2. **Canonicalise the document** Parse the response with a standard JSON parser and serialise the `document` object with JCS (RFC 8785). JCS sorts the members, removes whitespace and writes numbers and strings in one fixed form, so the same data always gives the same bytes, whatever order or spacing the response had. Canonicalise the parsed object; do not hash the response bytes or the output of your own JSON writer. 3. **Compare the hash** Compute SHA-256 over the canonical bytes and write it as `sha256:` followed by the lowercase hex digest. It must equal `verification.hash`. If it differs, the document you hold is not the one that was signed. 4. **Pick the key** Load the JWK set from `/.well-known/batterypass/keys.json` of the BatteryPass address you expect the passport to come from, and take the key whose `kid` equals `verification.key_id`: ```json title="keys.json" { "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "WuoaQRoQuh2kPs5yYB6HxNz3Y58ndE8fRHnzG10NxoU", "kid": "2026-10-k1", "use": "sig", "alg": "EdDSA" } ] } ``` The set needs no key, may be cached for an hour and also lists retired keys, so signatures of old versions keep verifying after a key change. 5. **Check the signature** Decode `x` (32 bytes) and `verification.signature` (64 bytes) from base64url without padding, and verify the Ed25519 signature over the canonical bytes (not over the hash). > [!WARNING] > **Where the keys come from** > > `verification.keys_url` and `links.keys` are not signed. Load the keys from the address you trust (the > manufacturer's BatteryPass address, or a key set you stored earlier), and treat a `keys_url` that points > somewhere else as a failed check. ## Complete code Each program takes the passport UUID as its argument and prints `valid`, or stops with the reason. ```ts title="TypeScript" // verify.mts: npm install canonicalize, then: npx tsx verify.mts import { createHash, createPublicKey, verify } from 'node:crypto'; import canonicalize from 'canonicalize'; const base = process.env.BATTERYPASS_URL; // the BatteryPass address that published the passport const passportId = process.argv[2]; // the UUID of urn:dpp:{uuid} async function getJSON(url: string) { const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url}: ${res.status}`); return res.json(); } const passport = await getJSON(`${base}/api/v1/public/passports/${passportId}`); const { document, verification } = passport; if (!document || !verification) throw new Error(`status ${passport.status}: nothing to verify`); // 1. Canonicalise the signed document (RFC 8785) and compare its SHA-256 with verification.hash. const canonical = Buffer.from(canonicalize(document) ?? '', 'utf8'); const hash = `sha256:${createHash('sha256').update(canonical).digest('hex')}`; if (hash !== verification.hash) throw new Error(`hash mismatch: ${hash}`); // 2. Take the key named by verification.key_id from the published JWK set. const { keys } = await getJSON(`${base}/.well-known/batterypass/keys.json`); const jwk = keys.find((k: { kid: string }) => k.kid === verification.key_id); if (!jwk) throw new Error(`unknown key ${verification.key_id}`); // 3. Check the Ed25519 signature (base64url, no padding) over the canonical bytes. const publicKey = createPublicKey({ key: jwk, format: 'jwk' }); const valid = verify(null, canonical, publicKey, Buffer.from(verification.signature, 'base64url')); console.log(valid ? 'valid' : 'INVALID'); process.exitCode = valid ? 0 : 1; ``` ```python title="Python" # verify.py: pip install requests cryptography rfc8785, then: python verify.py import base64 import hashlib import os import sys import requests import rfc8785 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey base = os.environ["BATTERYPASS_URL"] # the BatteryPass address that published the passport passport_id = sys.argv[1] # the UUID of urn:dpp:{uuid} def get_json(url): res = requests.get(url, timeout=10) res.raise_for_status() return res.json() def b64url(text): return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) passport = get_json(f"{base}/api/v1/public/passports/{passport_id}") document, verification = passport.get("document"), passport.get("verification") if not document or not verification: sys.exit(f"status {passport['status']}: nothing to verify") # 1. Canonicalise the signed document (RFC 8785) and compare its SHA-256 with verification.hash. canonical = rfc8785.dumps(document) digest = "sha256:" + hashlib.sha256(canonical).hexdigest() if digest != verification["hash"]: sys.exit(f"hash mismatch: {digest}") # 2. Take the key named by verification.key_id from the published JWK set. keys = get_json(f"{base}/.well-known/batterypass/keys.json")["keys"] jwk = next((k for k in keys if k["kid"] == verification["key_id"]), None) if jwk is None: sys.exit(f"unknown key {verification['key_id']}") # 3. Check the Ed25519 signature (base64url, no padding) over the canonical bytes. try: Ed25519PublicKey.from_public_bytes(b64url(jwk["x"])).verify(b64url(verification["signature"]), canonical) except InvalidSignature: sys.exit("INVALID") print("valid") ``` ```go title="Go" // verify: go get github.com/gowebpki/jcs, then: go run . package main import ( "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "os" "github.com/gowebpki/jcs" ) type publicPassport struct { Status string `json:"status"` Document json.RawMessage `json:"document"` // kept as received, canonicalised below Verification *struct { Hash string `json:"hash"` Signature string `json:"signature"` KeyID string `json:"key_id"` } `json:"verification"` } type jwkSet struct { Keys []struct { Kid string `json:"kid"` X string `json:"x"` } `json:"keys"` } func getJSON(url string, v any) error { res, err := http.Get(url) if err != nil { return err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return fmt.Errorf("GET %s: %s", url, res.Status) } return json.NewDecoder(res.Body).Decode(v) } func main() { base := os.Getenv("BATTERYPASS_URL") // the BatteryPass address that published the passport passportID := os.Args[1] // the UUID of urn:dpp:{uuid} var p publicPassport if err := getJSON(base+"/api/v1/public/passports/"+passportID, &p); err != nil { log.Fatal(err) } if p.Document == nil || p.Verification == nil { log.Fatalf("status %s: nothing to verify", p.Status) } // 1. Canonicalise the signed document (RFC 8785) and compare its SHA-256 with verification.hash. canonical, err := jcs.Transform(p.Document) if err != nil { log.Fatal(err) } sum := sha256.Sum256(canonical) if hash := "sha256:" + hex.EncodeToString(sum[:]); hash != p.Verification.Hash { log.Fatalf("hash mismatch: %s", hash) } // 2. Take the key named by verification.key_id from the published JWK set. var set jwkSet if err := getJSON(base+"/.well-known/batterypass/keys.json", &set); err != nil { log.Fatal(err) } var pub []byte for _, k := range set.Keys { if k.Kid == p.Verification.KeyID { pub, _ = base64.RawURLEncoding.DecodeString(k.X) } } if len(pub) != ed25519.PublicKeySize { log.Fatalf("unknown key %s", p.Verification.KeyID) } // 3. Check the Ed25519 signature (base64url, no padding) over the canonical bytes. sig, err := base64.RawURLEncoding.DecodeString(p.Verification.Signature) if err != nil || !ed25519.Verify(pub, canonical, sig) { log.Fatal("INVALID") } fmt.Println("valid") } ``` To check many passports at once, see the recipe [Verify signatures of several passports](https://passport.kabin.sh/docs/en/recipes/verify-signatures). ## When the check fails | Result | Meaning | What to do | | ------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | No `document` or `verification` | The passport is not public at the moment; `status` says why | Show the status; there is nothing to verify | | Hash mismatch | The document is not the one that was signed, or it was not canonicalised correctly | Canonicalise the parsed `document` with an RFC 8785 library; if it still differs, treat the data as not verified | | Unknown `key_id` | The key set you loaded has no such key | Reload the key set (yours may be older than the signature); if it is still missing, the check fails | | Invalid signature | The document was not signed with that key | Treat the data as not verified | ## Older versions [`getPublicPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-version) returns the public JSON of the version that was valid at a point in time (`?at=2026-09-01T00:00:00Z`), with `valid_until` set once a later version replaced it. Verify it in exactly the same way: versions never change after publication, and retired keys stay in the key set. ## Server-side check [`verifyPassport`](https://passport.kabin.sh/docs/en/api-reference/public/verify-passport) runs the same check on the server and, in addition, checks the full snapshot of the version, which a scanner never receives. Use it for a quick look or a monitoring job; the offline check above is the one that does not rely on the server's word. ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10/verify?version_no=3" ``` ```json title="200" { "valid": true, "version_no": 3, "key_id": "2026-10-k1", "public_document_valid": true, "snapshot_valid": true, "signed_at": "2026-10-20T09:12:44Z", "checked_at": "2026-10-24T08:00:00Z" } ``` Without `version_no`, it checks the current version. `valid` is `true` only when both signatures verify and both hashes match. --- # Push condition data from a battery management system > Report measured values such as state of charge or full cycles with updateCondition, and read what was accepted, skipped and re-published. A battery management system (BMS), a service tool or a monitoring platform reports what it measures with [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition). BatteryPass accepts the values as measurements, without a review step, records them with their measurement time and `source` `api`, adds a `condition_update` event that names the key (`api_key`) to the unit's lifecycle and re-publishes the passport according to your organisation's policy. The concepts are in [Condition data and lifecycle](https://passport.kabin.sh/docs/en/concepts/condition-and-lifecycle). ## Before you start - **A key with the scope `condition`.** An owner creates it in the portal under **Settings → API keys**. Reading (for example [`getCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/get-condition) or [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries)) needs the scope `read`; give the key both scopes if the system does both. See [Handle keys securely](https://passport.kabin.sh/docs/en/guides/secure-key-handling). - **The unit's id.** Store it next to the serial number in your system, for example with [Sync units](https://passport.kabin.sh/docs/en/guides/sync-units), or look it up with `listBatteries?serial_prefix=`. - **The fields.** Only the dynamic fields of the registry (section `condition`) are accepted, and only those that apply to the unit's battery category. `getCondition` lists them with their current values and tells you the re-publication policy: ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/condition" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` ```json title="200 (shortened)" { "battery_id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f", "field_keys": [ "condition.state_of_charge", "condition.remaining_capacity", "condition.capacity_fade", "condition.number_of_full_cycles" ], "values": [ { "field_key": "condition.number_of_full_cycles", "value": 412, "revision": 6, "source": "api", "review_status": "approved", "measured_at": "2026-10-23T06:00:00Z", "recorded_at": "2026-10-23T06:00:04Z", "registry_version": "0.5.0", "checks": [] } ], "republish": "immediate" } ``` ## Send an update `PATCH /api/v1/batteries/{batteryId}/condition` with a JSON body: - **values** `object[]` (body, required): 1 to 50 values, each field at most once. **value** - **field_key** `string` (required): A dynamic field of the unit, e.g. `condition.state_of_charge`. - **value** `any` (required): The value in the field's unit (e.g. `%` or `Ah`); `null` clears the field. - **measured_at** `date-time`: When this value was measured; defaults to the update's `measured_at`. * **measured_at** `date-time` (body): The measurement time of every value without its own. * **note** `string` (body): At most 500 characters; becomes the description of the lifecycle event. ```bash title="cURL" curl -sS -X PATCH "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/condition" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "measured_at": "2026-10-24T06:00:00Z", "note": "BMS daily report", "values": [ { "field_key": "condition.state_of_charge", "value": 81.5 }, { "field_key": "condition.number_of_full_cycles", "value": 412 }, { "field_key": "condition.remaining_capacity", "value": 187.2, "measured_at": "2026-10-24T05:40:00Z" } ] }' ``` ```ts title="TypeScript" const batteryId = '0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f'; const res = await fetch(`${process.env.BATTERYPASS_URL}/api/v1/batteries/${batteryId}/condition`, { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ measured_at: '2026-10-24T06:00:00Z', note: 'BMS daily report', values: [ { field_key: 'condition.state_of_charge', value: 81.5 }, { field_key: 'condition.number_of_full_cycles', value: 412 }, { field_key: 'condition.remaining_capacity', value: 187.2, measured_at: '2026-10-24T05:40:00Z' }, ], }), }); const result = await res.json(); if (!res.ok) throw new Error(`${res.status} ${result.code} (request_id ${result.request_id})`); console.log(result.publication.outcome, result.skipped); ``` ```python title="Python" import os import requests battery_id = "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f" res = requests.patch( f"{os.environ['BATTERYPASS_URL']}/api/v1/batteries/{battery_id}/condition", headers={"Authorization": f"Bearer {os.environ['BATTERYPASS_API_KEY']}"}, json={ "measured_at": "2026-10-24T06:00:00Z", "note": "BMS daily report", "values": [ {"field_key": "condition.state_of_charge", "value": 81.5}, {"field_key": "condition.number_of_full_cycles", "value": 412}, {"field_key": "condition.remaining_capacity", "value": 187.2, "measured_at": "2026-10-24T05:40:00Z"}, ], }, timeout=30, ) result = res.json() if not res.ok: raise SystemExit(f"{res.status_code} {result['code']} (request_id {result.get('request_id')})") print(result["publication"]["outcome"], result["skipped"]) ``` ```go title="Go" package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" ) func main() { batteryID := "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f" body, _ := json.Marshal(map[string]any{ "measured_at": "2026-10-24T06:00:00Z", "note": "BMS daily report", "values": []map[string]any{ {"field_key": "condition.state_of_charge", "value": 81.5}, {"field_key": "condition.number_of_full_cycles", "value": 412}, {"field_key": "condition.remaining_capacity", "value": 187.2, "measured_at": "2026-10-24T05:40:00Z"}, }, }) url := os.Getenv("BATTERYPASS_URL") + "/api/v1/batteries/" + batteryID + "/condition" req, _ := http.NewRequest(http.MethodPatch, url, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() var result struct { Skipped []map[string]string `json:"skipped"` Publication struct { Outcome string `json:"outcome"` } `json:"publication"` Code string `json:"code"` RequestID string `json:"request_id"` } if err := json.NewDecoder(res.Body).Decode(&result); err != nil { log.Fatal(err) } if res.StatusCode != http.StatusOK { log.Fatalf("%d %s (request_id %s)", res.StatusCode, result.Code, result.RequestID) } fmt.Println(result.Publication.Outcome, result.Skipped) } ``` ```json title="200 (shortened)" { "accepted": [ { "field_key": "condition.state_of_charge", "value": 81.5, "unit": "%", "revision": 9, "source": "api", "review_status": "approved", "measured_at": "2026-10-24T06:00:00Z", "recorded_at": "2026-10-24T06:00:03Z", "reviewed_at": "2026-10-24T06:00:03Z", "registry_version": "0.5.0", "checks": [] }, { "field_key": "condition.remaining_capacity", "value": 187.2, "unit": "Ah", "revision": 5, "source": "api", "review_status": "approved", "measured_at": "2026-10-24T05:40:00Z", "recorded_at": "2026-10-24T06:00:03Z", "reviewed_at": "2026-10-24T06:00:03Z", "registry_version": "0.5.0", "checks": [] } ], "skipped": [ { "field_key": "condition.number_of_full_cycles", "reason": "unchanged" } ], "event": { "id": "0192f8b1-2a3c-7d4e-9f50-61a2b3c4d5e6", "battery_id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f", "type": "condition_update", "occurred_on": "2026-10-24", "description": "BMS daily report", "source": "api", "api_key": { "id": "0192e4c7-8b9a-7c6d-8e5f-4a3b2c1d0e9f", "name": "BMS Landshut" }, "field_keys": ["condition.state_of_charge", "condition.remaining_capacity"], "evidence": [], "created_at": "2026-10-24T06:00:03Z" }, "publication": { "outcome": "published", "version": { "version_no": 4, "published_at": "2026-10-24T06:00:03Z", "reason": "condition update", "registry_version": "0.5.0", "canonical_hash": "sha256:5d0c9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d", "public_hash": "sha256:7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d5d0c9e8f7a6b5c4d3e2f1a0b9c8d", "key_id": "2026-10-k1", "batterypass_v2_valid": true, "batterypass_v2_issue_count": 0 } } } ``` ## Read the result `accepted` holds the stored values with `source: api`, `review_status: approved` and their `measured_at`. `skipped` lists values that were valid but not stored: | `reason` | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------ | | `older_measurement` | The stored value was measured later than the one you sent. | | `unchanged` | The stored value has the same value and the same measurement time, or you cleared a field that has no value. | `event` is the `condition_update` event in the unit's lifecycle; it is missing when nothing was accepted. Its `occurred_on` is the UTC day of the latest accepted measurement, and `note` becomes its `description`. `publication.outcome` says what happened to the passport: | `outcome` | Meaning | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `published` | A new passport version with the reason `condition update` is online now (`version`). | | `unchanged` | Published immediately, but the public content did not change, so no version was added. | | `scheduled` | The organisation re-publishes daily: the job at 02:00 UTC publishes the new values. | | `blocked` | The publish gate refused (`blockers`). The values are stored; the passport stays online in status `update_needed` until a member publishes it again. | | `not_published` | The unit has no public passport; the values appear with its first publication. | The policy (`immediate` or `daily`) is an organisation setting in the portal; `getCondition` shows it as `republish`. Most condition fields belong to the access tier `legitimate_interest`: they are part of the signed snapshot and the restricted views, while the public JSON only lists their keys in `restricted_fields_present` (see [Access tiers](https://passport.kabin.sh/docs/en/concepts/access-tiers)). ## Rules of an update - **Merge.** Only the fields you name change; every other field keeps its value. `null` clears a field. - **Measurement time.** Every value needs a `measured_at`, its own or the request's. It may lie at most five minutes ahead of the server clock and not before the unit's manufacturing date. Send it in UTC (`Z`). - **Newest wins.** A value measured earlier than the stored one is skipped, so late or repeated deliveries do no harm. Sending the same update twice is safe: the second time everything comes back as `unchanged`. - **All or nothing.** If one value is invalid, or the merged condition data fails a cross-field check of the registry, the whole update is refused and nothing is stored. - **Values, not documents.** `updateCondition` takes no `evidence_ids`; to attach a diagnostic report, record a [lifecycle event](https://passport.kabin.sh/docs/en/guides/record-lifecycle-events). ## Errors ```json title="400" { "type": "about:blank", "title": "Bad Request", "status": 400, "code": "validation_error", "detail": "The request failed validation.", "request_id": "4f9c2a7d1e0b5c8a3f6e2d19", "errors": [ { "field": "values[0].field_key", "code": "unknown_field", "message": "performance.nominal_voltage is not condition data of this unit" }, { "field": "values[1].measured_at", "code": "out_of_range", "message": "measured_at lies in the future" } ] } ``` | Status and `code` | Typical cause | What to do | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | `400 validation_error` | `values[i].field_key` with `unknown_field` (not a dynamic field of this unit); `values[i].measured_at` with `required` or `out_of_range`; `values[i].value` outside the field's range; a field twice; a failing cross-field check; `note` too long | Fix the data; do not retry unchanged | | `401 unauthenticated` | Key missing, unknown or revoked | Check the key in your secret store | | `403 forbidden` | The key lacks the scope `condition` | Have an owner create a key with that scope | | `404 not_found` | The unit does not exist in the key's organisation | Check the id | | `409 conflict` | The unit's battery model is archived: its data can be read but not changed | Stop reporting for units of that model | | `429 rate_limited` | More than 600 requests per minute for all keys of the organisation | Wait for `Retry-After`, then send again | [Handle errors and retries](https://passport.kabin.sh/docs/en/guides/handle-errors) shows a retry helper; the recipe [Nightly condition push](https://passport.kabin.sh/docs/en/recipes/nightly-condition-push) runs one update per unit with backoff. --- # Record lifecycle events > Report inspections, repairs and other events in the life of a unit with createLifecycleEvent, and read the lifecycle with listLifecycleEvents. A lifecycle event records that something happened to a battery: it was put into service, inspected, repaired, a component was replaced. Events are records: they change none of the unit's data. Measured values go through [`updateCondition`](https://passport.kabin.sh/docs/en/guides/push-condition-data). Background: [Condition data and lifecycle](https://passport.kabin.sh/docs/en/concepts/condition-and-lifecycle). | Operation | Scope | What it does | | ------------------------------------------------------------------------------------------------------------ | ----------- | ---------------------------------- | | [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event) | `condition` | Records one event for a unit | | [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) | `read` | The unit's lifecycle, newest first | ## Event types | `type` | Use it for | | -------------------- | ----------------------------------------------- | | `placed_on_market` | The battery was placed on the market. | | `put_into_service` | The battery was installed and put into service. | | `inspection` | A check or maintenance visit. | | `repair` | A repair. | | `component_replaced` | A module, cell or other component was replaced. | | `waste` | The battery became waste. | | `recycled` | The battery was handed over for recycling. | | `custom` | Anything else; say what in `description`. | The contract's `ReportedEventType` also lists `condition_update`, but `createLifecycleEvent` refuses it with `400 validation_error` on `type`: those events are written by `updateCondition`. The second-life types (`repurposed`, `reused`, `remanufactured`) appear in the lifecycle when a member creates a second-life unit in the portal; they cannot be recorded through the API. ## Record an event 1. **Find the documents (optional)** `evidence_ids` names documents of your organisation's evidence library, for example a diagnostic report or a repair protocol, at most 20 and each once. Members upload documents in the portal; find their ids with [`listEvidence`](https://passport.kabin.sh/docs/en/api-reference/evidence/list-evidence) (key with scope `read`), for example `?kind=diagnostic_report`. 2. **Send the event** `POST /api/v1/batteries/{batteryId}/lifecycle-events` - **type** `string` (body, required): One of the types above. - **occurred_on** `date` (body, required): The day it happened (`YYYY-MM-DD`), between the unit's manufacturing date and today. - **description** `string` (body): At most 2000 characters. - **evidence_ids** `uuid[]` (body): At most 20 distinct documents of the evidence library. ```bash title="cURL" curl -sS -X POST "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/lifecycle-events" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "inspection", "occurred_on": "2026-10-22", "description": "Annual inspection: housing, connectors and BMS log checked, no findings.", "evidence_ids": ["0192f7aa-1c2d-7e3f-8a4b-5c6d7e8f9a0b"] }' ``` ```ts title="TypeScript" const batteryId = '0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f'; const res = await fetch(`${process.env.BATTERYPASS_URL}/api/v1/batteries/${batteryId}/lifecycle-events`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'inspection', occurred_on: '2026-10-22', description: 'Annual inspection: housing, connectors and BMS log checked, no findings.', evidence_ids: ['0192f7aa-1c2d-7e3f-8a4b-5c6d7e8f9a0b'], }), }); const event = await res.json(); if (res.status !== 201) throw new Error(`${res.status} ${event.code} (request_id ${event.request_id})`); console.log(event.id, event.type, event.occurred_on); ``` ```python title="Python" import os import requests battery_id = "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f" res = requests.post( f"{os.environ['BATTERYPASS_URL']}/api/v1/batteries/{battery_id}/lifecycle-events", headers={"Authorization": f"Bearer {os.environ['BATTERYPASS_API_KEY']}"}, json={ "type": "inspection", "occurred_on": "2026-10-22", "description": "Annual inspection: housing, connectors and BMS log checked, no findings.", "evidence_ids": ["0192f7aa-1c2d-7e3f-8a4b-5c6d7e8f9a0b"], }, timeout=30, ) event = res.json() if res.status_code != 201: raise SystemExit(f"{res.status_code} {event['code']} (request_id {event.get('request_id')})") print(event["id"], event["type"], event["occurred_on"]) ``` ```go title="Go" package main import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" ) func main() { batteryID := "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f" body, _ := json.Marshal(map[string]any{ "type": "inspection", "occurred_on": "2026-10-22", "description": "Annual inspection: housing, connectors and BMS log checked, no findings.", "evidence_ids": []string{"0192f7aa-1c2d-7e3f-8a4b-5c6d7e8f9a0b"}, }) url := os.Getenv("BATTERYPASS_URL") + "/api/v1/batteries/" + batteryID + "/lifecycle-events" req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) // do not resend blindly: check listLifecycleEvents first } defer res.Body.Close() var event map[string]any if err := json.NewDecoder(res.Body).Decode(&event); err != nil { log.Fatal(err) } if res.StatusCode != http.StatusCreated { log.Fatalf("%d %v (request_id %v)", res.StatusCode, event["code"], event["request_id"]) } fmt.Println(event["id"], event["type"], event["occurred_on"]) } ``` ```json title="201" { "id": "0192f8c2-4d5e-7f60-8a71-b2c3d4e5f6a7", "battery_id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f", "type": "inspection", "occurred_on": "2026-10-22", "description": "Annual inspection: housing, connectors and BMS log checked, no findings.", "source": "api", "api_key": { "id": "0192e4c7-8b9a-7c6d-8e5f-4a3b2c1d0e9f", "name": "Service system North" }, "field_keys": [], "evidence": [ { "id": "0192f7aa-1c2d-7e3f-8a4b-5c6d7e8f9a0b", "kind": "diagnostic_report", "title": "Inspection report 2026", "filename": "inspection-2026-10-22.pdf", "access_tier": "internal", "revision": 1, "superseded": false } ], "created_at": "2026-10-24T08:15:02Z" } ``` An event sent with a key has `source: api` and names the key in `api_key`; events of members have `reported_by`, those of service partners `partner`. ## No automatic de-duplication BatteryPass does not recognise the same event sent twice: every successful request adds an event, and `createLifecycleEvent` takes no idempotency key. That matters when a request times out and you do not know whether it arrived: - Do not resend a `POST` automatically after a network error, a timeout or a `5xx`. - Before you send it again, look for your event (same `type`, `occurred_on` and `description`, `source: api`) in `listLifecycleEvents`. The list is ordered by `occurred_on`, newest first, so a backdated event is not necessarily on the first page: read on with `next_cursor` until the page holds events that occurred before yours (the [retry sample](https://passport.kabin.sh/docs/en/idempotency) does exactly that). - Keep the returned `id` in your system, so you know which events are already recorded. A `429 rate_limited` is safe to resend after `Retry-After`: the request was refused before anything was stored. More in [Retries and idempotency](https://passport.kabin.sh/docs/en/idempotency). ## Read the lifecycle ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/lifecycle-events?limit=50" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` The answer is a page `{items, next_cursor}`, newest `occurred_on` first; follow `next_cursor` for older events ([Pagination](https://passport.kabin.sh/docs/en/pagination)). The list holds every event of the unit, whoever reported it: members, service partners through their links, condition updates (`type: condition_update` with the updated `field_keys`), keys and the system. For a second life, `related_battery_id` and `related_serial_number` name the other unit. ## Errors | Status and `code` | Typical cause | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400 validation_error` | `type` not allowed (including `condition_update`); `occurred_on` missing, in the future or before the manufacturing date; `description` too long; an entry of `evidence_ids` that is not a document of your organisation or appears twice | | `401 unauthenticated` | Key missing, unknown or revoked | | `403 forbidden` | The key lacks the scope `condition` (for `listLifecycleEvents`: `read`) | | `404 not_found` | The unit does not exist in the key's organisation | | `409 conflict` | The unit's battery model is archived | | `429 rate_limited` | Too many requests; wait for `Retry-After` | --- # Export passports > Download the BatteryPass JSON, the PDF and the QR code of a passport version, and the units of your organisation as a spreadsheet. All exports are plain `GET` requests with a key that has the scope `read`. They come back as files with a `Content-Disposition: attachment` header that carries a ready-made file name. | Export | Operation | Content type | File name | | ---------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------------------- | | BatteryPass v2.0 JSON of a version | [`exportPassportJson`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-json) | `application/json` | `{gtin}-{serial}-batterypass-v2.json` | | PDF of a version | [`exportPassportPdf`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-pdf) | `application/pdf` | `{gtin}-{serial}-v{versionNo}-{lang}.pdf` | | QR code of the unit | [`getPassportQr`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr) | `image/png` or `image/svg+xml` | `qr-{gtin}-{serial}.png` or `.svg` | | Units as a spreadsheet | [`exportUnits`](https://passport.kabin.sh/docs/en/api-reference/catalog/export-units) | XLSX or `text/csv` | `units-{YYYYMMDD}.xlsx` or `.csv` | A `/` in a serial number becomes `_` in file names. ## Passport exports step by step 1. **Find the version** The JSON and the PDF belong to a published version. [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport) returns the current one in `current_version.version_no`; [`listPassportVersions`](https://passport.kabin.sh/docs/en/api-reference/passports/list-passport-versions) lists all versions, newest first. ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/passport" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" | jq '{status, passport_id, version: .current_version.version_no}' ``` A unit that was never published has no version: the version exports answer `404 not_found`. 2. **Download the files** ```bash title="cURL" BATTERY_ID=0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f # -O -J saves under the name from Content-Disposition curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/versions/3/batterypass-v2" curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/versions/3/pdf?lang=en" curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/qr?format=svg" ``` In code, read the version first and take the file name from `Content-Disposition`: ```ts title="TypeScript" // exports.mts: npx tsx exports.mts import { writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }; const batteryId = process.argv[2]; async function download(path: string) { const res = await fetch(BASE + path, { headers }); if (!res.ok) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`); // Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-en.pdf const match = /filename="?([^";]+)"?/.exec(res.headers.get('Content-Disposition') ?? ''); const file = basename(match?.[1] ?? 'download'); await writeFile(file, Buffer.from(await res.arrayBuffer())); console.log(file, res.headers.get('Content-Type')); } const passportRes = await fetch(`${BASE}/batteries/${batteryId}/passport`, { headers }); const passport = await passportRes.json(); const versionNo = passport.current_version?.version_no; if (!versionNo) throw new Error(`passport is ${passport.status}: no published version yet`); await download(`/batteries/${batteryId}/passport/versions/${versionNo}/batterypass-v2`); await download(`/batteries/${batteryId}/passport/versions/${versionNo}/pdf?lang=en`); await download(`/batteries/${batteryId}/passport/qr?format=svg`); ``` ```python title="Python" # exports.py: python exports.py import os import re import sys import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"] battery_id = sys.argv[1] def download(path, **params): res = session.get(BASE + path, params=params, timeout=60) res.raise_for_status() # Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-en.pdf match = re.search(r'filename="?([^";]+)"?', res.headers.get("Content-Disposition", "")) name = os.path.basename(match.group(1) if match else "download") with open(name, "wb") as f: f.write(res.content) print(name, res.headers["Content-Type"]) passport = session.get(f"{BASE}/batteries/{battery_id}/passport", timeout=30).json() version_no = passport.get("current_version", {}).get("version_no") if not version_no: sys.exit(f"passport is {passport['status']}: no published version yet") download(f"/batteries/{battery_id}/passport/versions/{version_no}/batterypass-v2") download(f"/batteries/{battery_id}/passport/versions/{version_no}/pdf", lang="en") download(f"/batteries/{battery_id}/passport/qr", format="svg") ``` ```go title="Go" // exports: go run . package main import ( "encoding/json" "fmt" "io" "log" "mime" "net/http" "os" "path/filepath" ) var ( base = os.Getenv("BATTERYPASS_URL") + "/api/v1" key = os.Getenv("BATTERYPASS_API_KEY") ) func get(path string) (*http.Response, error) { req, err := http.NewRequest(http.MethodGet, base+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) res, err := http.DefaultClient.Do(req) if err == nil && res.StatusCode != http.StatusOK { res.Body.Close() return nil, fmt.Errorf("%s: %s", path, res.Status) } return res, err } func download(path string) error { res, err := get(path) if err != nil { return err } defer res.Body.Close() // Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-en.pdf _, params, err := mime.ParseMediaType(res.Header.Get("Content-Disposition")) if err != nil { return err } name := filepath.Base(params["filename"]) f, err := os.Create(name) if err != nil { return err } defer f.Close() if _, err := io.Copy(f, res.Body); err != nil { return err } fmt.Println(name, res.Header.Get("Content-Type")) return nil } func main() { batteryID := os.Args[1] res, err := get("/batteries/" + batteryID + "/passport") if err != nil { log.Fatal(err) } var passport struct { Status string `json:"status"` CurrentVersion *struct { VersionNo int `json:"version_no"` } `json:"current_version"` } err = json.NewDecoder(res.Body).Decode(&passport) res.Body.Close() if err != nil { log.Fatal(err) } if passport.CurrentVersion == nil { log.Fatalf("passport is %s: no published version yet", passport.Status) } version := fmt.Sprintf("/batteries/%s/passport/versions/%d", batteryID, passport.CurrentVersion.VersionNo) for _, path := range []string{ version + "/batterypass-v2", version + "/pdf?lang=en", "/batteries/" + batteryID + "/passport/qr?format=svg", } { if err := download(path); err != nil { log.Fatal(err) } } } ``` ## BatteryPass JSON `exportPassportJson` returns the export that was built and stored when the version was published, in the format of the BatteryPass-Ready Data Model v2.0. It contains every access tier, including restricted values, so treat the file as confidential. Whether it passed the data model's JSON schema is advisory and does not block publication: the version summary carries `batterypass_v2_valid` and `batterypass_v2_issue_count`, and [`getPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version) the full report in `batterypass_v2`. ## PDF `exportPassportPdf` renders a version with every tier (restricted rows tagged with their tier), the signature and the QR code. `lang` is `de` (the default) or `en`. Like the JSON, the PDF contains restricted data. ## QR code `getPassportQr` renders the unit's `gs1_digital_link` (error correction level M, a quiet zone of four modules). - **format** `string` (query, default: png): `png` or `svg`. SVG scales without loss and suits printing. - **size** `integer` (query, default: 512): Width and height of the PNG in pixels, 128 to 2048. Ignored for SVG. The QR code exists once the identifiers are locked, before or at the first publication. Before that, the answer is `409 conflict`. The link inside it never changes afterwards, so a printed label stays valid for later versions. To label many units at once, see the recipe [QR labels for a batch](https://passport.kabin.sh/docs/en/recipes/qr-labels-for-a-batch). ## Units as a spreadsheet `exportUnits` returns the units that match the filters, with the columns of the spreadsheet import: model, version, the unit's own fields and every unit-level field that has a value. A member can edit the file and import it again in the portal. - **format** `string` (query, default: xlsx): `xlsx` or `csv`. CSV is UTF-8 with a byte order mark, comma-separated, with CRLF line ends. - **battery_model_id** `uuid` (query): Only units of this model. - **model_version_id** `uuid` (query): Only units of this model version. - **passport_status** `string` (query): Only units whose passport has this status, e.g. `published`. - **serial_prefix** `string` (query): Only serial numbers that start with this text. - **import_id** `uuid` (query): Only units created by this import. ```bash title="cURL" curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ "$BATTERYPASS_URL/api/v1/batteries/export?format=csv&passport_status=published&serial_prefix=SV2027-" ``` At most 10 000 units fit in one file. With more, the answer is `400 validation_error` on `filter` (`out_of_range`): narrow the filters, for example by model version or serial number prefix. In CSV files, a cell that starts with `=`, `+`, `@`, or with `-` but is not a number, gets a leading `'` so spreadsheet programs do not run it as a formula; the import removes it again. ## Errors | Status and `code` | Typical cause | | ---------------------- | -------------------------------------------------------------------------------------------------- | | `400 validation_error` | `size` outside 128 to 2048; an unknown `format` or `lang`; more than 10 000 units in `exportUnits` | | `401 unauthenticated` | Key missing, unknown or revoked | | `403 forbidden` | The key lacks the scope `read` | | `404 not_found` | Unknown unit, or no version with this number | | `409 conflict` | `getPassportQr` before the identifiers are locked | | `429 rate_limited` | Too many requests; wait for `Retry-After` | --- # 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)). --- # Handle errors and retries > Decide which answers to retry and which to fix, back off with jitter, honour Retry-After and log the request_id. Every error of the API is an RFC 9457 Problem (`application/problem+json`) with a stable `code` and a `request_id`. Some errors go away when you wait and try again; most need a change on your side. This guide sorts them and shows a small helper that retries the right ones. The codes are listed in [Errors](https://passport.kabin.sh/docs/en/errors). ```json title="429" { "type": "about:blank", "title": "Too Many Requests", "status": 429, "code": "rate_limited", "detail": "Too many attempts. Try again later.", "request_id": "8b1e6f03c2d94a7e5f0a1b2c" } ``` The `request_id` is also in the `X-Request-Id` header of every response, including successful ones. ## Retry or fix? | Answer | Retry? | What to do | | ----------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `429 rate_limited` | Yes, always | Wait the number of seconds in `Retry-After`. The request was refused before it did anything. | | `500 internal_error`, `502`, `503`, `504` | `GET` and `updateCondition`: yes, with backoff. `createLifecycleEvent`: not automatically | Back off and try again; if it persists, contact support with the `request_id`. | | Network error or timeout | Same as `5xx` | You do not know whether the request arrived. | | `400 validation_error` | No | Fix the request; `errors[]` names each field (`field`, `code`, `message`). | | `401 unauthenticated` | No | The key is missing, unknown or revoked. Alert a person instead of looping. | | `403 forbidden` | No | The key lacks the operation's scope (`read`, `condition` or `import`). | | `404 not_found` | No | Wrong id, or the resource belongs to another organisation. | | `409 conflict` | No | The resource is in a state that does not allow this, e.g. a QR code before the identifiers are locked, or condition data for a unit of an archived model. | Why the difference between methods: `GET` changes nothing, and `updateCondition` skips a measurement it already has, so sending the same body again is harmless. `createLifecycleEvent` records a new event on every successful call; after a timeout, check [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) before you send it again. Details in [Retries and idempotency](https://passport.kabin.sh/docs/en/idempotency). > [!WARNING] > Do not retry `401` in a loop. Unknown and revoked keys are limited to 30 attempts per 15 minutes per client > address, so a misconfigured job soon gets `429` instead of `401`, which hides the real cause. ## Back off with jitter When an answer is worth retrying: 1. If `Retry-After` is present, wait that many seconds. 2. Otherwise wait a random time between 0 and `min(30 s, 0.5 s × 2^attempt)` ("full jitter"), so many clients do not retry in lockstep. 3. Give up after a few attempts (five is plenty) and report the error with its `request_id`. 4. Set a client timeout (for example 30 seconds) so a hanging connection counts as a network error. In batch jobs, a `429` means the whole organisation is over its limit of 600 requests per minute (all keys together). Slow the job down instead of only retrying the single request; see [Rate limits](https://passport.kabin.sh/docs/en/rate-limits). ## A helper for every call The helper sends a request, returns the parsed JSON, retries what is worth retrying and raises an error that carries the Problem. Each version runs on its own; the last lines show its use. ```ts title="TypeScript" // batterypass.mts: npx tsx batterypass.mts const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const KEY = process.env.BATTERYPASS_API_KEY; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export class ApiError extends Error { status: number; problem: { code?: string; detail?: string; request_id?: string; errors?: unknown[] }; constructor(status: number, problem: ApiError['problem']) { super(`${status} ${problem.code}: ${problem.detail} (request_id ${problem.request_id})`); this.status = status; this.problem = problem; } } /** Sends a request; retries 429 always, and 5xx or network errors unless the method is POST. */ export async function call(method: string, path: string, body?: unknown, maxAttempts = 5): Promise { for (let attempt = 1; ; attempt++) { let res: Response | undefined; try { res = await fetch(BASE + path, { method, headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); } catch (err) { if (method === 'POST' || attempt === maxAttempts) throw err; // network error or timeout } if (res?.ok) return (await res.json()) as T; const retryable = !res || res.status === 429 || (res.status >= 500 && method !== 'POST'); if (res && (!retryable || attempt === maxAttempts)) { const requestId = res.headers.get('X-Request-Id') ?? undefined; const problem = await res.json().catch(() => ({ request_id: requestId })); throw new ApiError(res.status, problem); } // Retry-After (seconds) wins; otherwise exponential backoff with full jitter, at most 30 s. const retryAfter = Number(res?.headers.get('Retry-After')); await sleep(retryAfter > 0 ? retryAfter * 1000 : Math.random() * Math.min(30_000, 500 * 2 ** attempt)); } } // Usage try { const page = await call<{ items: { serial_number: string }[] }>('GET', '/batteries?limit=10'); console.log(page.items.map((u) => u.serial_number)); } catch (err) { if (err instanceof ApiError) console.error(err.message); // log the request_id, never the key else throw err; } ``` ```python title="Python" # batterypass.py import os import random import time import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"] class ApiError(Exception): def __init__(self, status, problem): super().__init__(f"{status} {problem.get('code')}: {problem.get('detail')} (request_id {problem.get('request_id')})") self.status, self.problem = status, problem def call(method, path, body=None, max_attempts=5): """Sends a request; retries 429 always, and 5xx or network errors unless the method is POST.""" for attempt in range(1, max_attempts + 1): try: res = session.request(method, BASE + path, json=body, timeout=30) except (requests.ConnectionError, requests.Timeout): # network error or timeout if method == "POST" or attempt == max_attempts: raise res = None if res is not None and res.ok: return res.json() retryable = res is None or res.status_code == 429 or (res.status_code >= 500 and method != "POST") if res is not None and (not retryable or attempt == max_attempts): try: problem = res.json() except ValueError: problem = {"request_id": res.headers.get("X-Request-Id")} raise ApiError(res.status_code, problem) # Retry-After (seconds) wins; otherwise exponential backoff with full jitter, at most 30 s. retry_after = res.headers.get("Retry-After") if res is not None else None time.sleep(int(retry_after) if retry_after else random.uniform(0, min(30, 0.5 * 2**attempt))) # Usage try: page = call("GET", "/batteries?limit=10") print([unit["serial_number"] for unit in page["items"]]) except ApiError as err: print(err) # log the request_id, never the key ``` ```go title="Go" // batterypass: go run . package main import ( "bytes" "encoding/json" "fmt" "io" "log" "math/rand/v2" "net/http" "os" "strconv" "time" ) var ( base = os.Getenv("BATTERYPASS_URL") + "/api/v1" key = os.Getenv("BATTERYPASS_API_KEY") client = &http.Client{Timeout: 30 * time.Second} ) // Problem is an RFC 9457 error response. type Problem struct { Status int `json:"status"` Code string `json:"code"` Detail string `json:"detail"` RequestID string `json:"request_id"` } func (p *Problem) Error() string { return fmt.Sprintf("%d %s: %s (request_id %s)", p.Status, p.Code, p.Detail, p.RequestID) } // call sends a request and decodes the response into out; it retries 429 always, and 5xx or // network errors unless the method is POST. func call(method, path string, body, out any) error { var payload []byte if body != nil { var err error if payload, err = json.Marshal(body); err != nil { return err } } const maxAttempts = 5 for attempt := 1; ; attempt++ { req, err := http.NewRequest(method, base+path, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") res, err := client.Do(req) if err != nil { if method == http.MethodPost || attempt == maxAttempts { return err // network error or timeout } } else { data, _ := io.ReadAll(res.Body) res.Body.Close() if res.StatusCode < 300 { return json.Unmarshal(data, out) } retryable := res.StatusCode == 429 || (res.StatusCode >= 500 && method != http.MethodPost) if !retryable || attempt == maxAttempts { p := &Problem{Status: res.StatusCode, RequestID: res.Header.Get("X-Request-Id")} _ = json.Unmarshal(data, p) return p } // Retry-After (seconds) wins. if s, _ := strconv.Atoi(res.Header.Get("Retry-After")); s > 0 { time.Sleep(time.Duration(s) * time.Second) continue } } // Exponential backoff with full jitter, at most 30 s. backoff := min(30*time.Second, 500*time.Millisecond< # Resolve a QR code or GS1 Digital Link > From a scanned GS1 Digital Link to the public passport JSON, through the resolver or by GTIN and serial number. The QR code on a battery holds a GS1 Digital Link such as `https://batterypass.example.com/01/04012345000023/21/SV2027-000184`. A browser that opens it lands on the public passport page. A scanner app, a recycler's system or a marketplace usually wants the data instead: this guide gets the public passport JSON from the link. No key is needed. The background is in [GS1 Digital Link and resolver](https://passport.kabin.sh/docs/en/concepts/digital-link). ## Three ways to the JSON | Way | Request | Answer | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Follow the link (recommended) | The link with `?linkType=describedby` | `307` to the public JSON | | Ask for the linkset | The link with `?linkType=all`, or with `Accept: application/json` and no `linkType` | `200` with `application/linkset+json`; the `describedby` entry is the JSON | | Look it up by GTIN and serial number | [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id) | `200` with the public JSON | Following the link works for any BatteryPass instance and any resolver hostname, because the link itself says where to go. The lookup needs you to know the BatteryPass address that holds the passport. ## Step by step 1. **Parse the link** A BatteryPass Digital Link has the path `/01/{gtin}/21/{serial}`: application identifier `01` with the model's GTIN (14 digits), `21` with the unit's serial number. Serial numbers consist of letters, digits, `.`, `-` and `/`; a `/` travels percent-encoded as `%2F`, so decode the segment after splitting the path, not before. Keep the origin of the link as it is: it may be a dedicated resolver hostname rather than the API's address. 2. **Request the JSON** Add `linkType=describedby` and send `Accept: application/json`, so an unknown passport comes back as a JSON Problem rather than an HTML page: ```bash title="cURL" curl -sS -i -H "Accept: application/json" \ "https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby" ``` ```http title="307" HTTP/1.1 307 Temporary Redirect Location: https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10 Link: ; rel="https://gs1.org/voc/pip"; type="text/html", ; rel="describedby"; type="application/json", ; rel="linkset"; type="application/linkset+json" Cache-Control: public, max-age=300 Vary: Accept ``` HTTP clients follow the `307` on their own (`curl -L`, `fetch`, `requests`, Go's `http.Client`). The target is [`getPublicPassport`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport). 3. **Read the passport** The JSON has `passport_id` (`urn:dpp:{uuid}`), `status`, `links` and, while the passport is public, the signed `document` with its `verification`. A passport whose identifiers are locked but that is not published yet (`qr_locked`), or one that is `suspended`, `withdrawn` or `deleted_recycled`, still resolves, but its JSON carries only the status, without `document`. To be sure the data is genuine, check the signature as shown in [Verify a passport signature offline](https://passport.kabin.sh/docs/en/guides/verify-signature). ## Complete code Each program takes the scanned link as its argument and prints GTIN, serial number, passport id, status and the URL of the JSON. ```ts title="TypeScript" // resolve.mts: npx tsx resolve.mts const link = new URL(process.argv[2]); // e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184 const match = /\/01\/(\d{8,14})\/21\/([^/]+)$/.exec(link.pathname); if (!match) throw new Error('not a GS1 Digital Link with GTIN and serial number'); const gtin = match[1]; const serial = decodeURIComponent(match[2]); // a "/" in the serial number arrives as %2F // linkType=describedby: the resolver answers 307 with the public JSON as Location; fetch follows it. link.searchParams.set('linkType', 'describedby'); const res = await fetch(link, { headers: { Accept: 'application/json' } }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.code}: ${body.detail}`); console.log(gtin, serial, body.passport_id, body.status, res.url); ``` ```python title="Python" # resolve.py: python resolve.py import re import sys from urllib.parse import unquote, urlsplit import requests link = sys.argv[1] # e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184 match = re.search(r"/01/(\d{8,14})/21/([^/]+)$", urlsplit(link).path) if not match: sys.exit("not a GS1 Digital Link with GTIN and serial number") gtin, serial = match.group(1), unquote(match.group(2)) # a "/" in the serial number arrives as %2F # linkType=describedby: the resolver answers 307 with the public JSON as Location; requests follows it. res = requests.get(link, params={"linkType": "describedby"}, headers={"Accept": "application/json"}, timeout=10) body = res.json() if not res.ok: sys.exit(f"{res.status_code} {body['code']}: {body.get('detail')}") print(gtin, serial, body["passport_id"], body["status"], res.url) ``` ```go title="Go" // resolve: go run . package main import ( "encoding/json" "fmt" "log" "net/http" "net/url" "os" "regexp" ) var digitalLink = regexp.MustCompile(`/01/(\d{8,14})/21/([^/]+)$`) func main() { link, err := url.Parse(os.Args[1]) // e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184 if err != nil { log.Fatal(err) } m := digitalLink.FindStringSubmatch(link.EscapedPath()) if m == nil { log.Fatal("not a GS1 Digital Link with GTIN and serial number") } gtin := m[1] serial, err := url.PathUnescape(m[2]) // a "/" in the serial number arrives as %2F if err != nil { log.Fatal(err) } // linkType=describedby: the resolver answers 307 with the public JSON as Location; the client follows it. q := link.Query() q.Set("linkType", "describedby") link.RawQuery = q.Encode() req, err := http.NewRequest(http.MethodGet, link.String(), nil) if err != nil { log.Fatal(err) } req.Header.Set("Accept", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() var body struct { PassportID string `json:"passport_id"` Status string `json:"status"` Code string `json:"code"` Detail string `json:"detail"` } if err := json.NewDecoder(res.Body).Decode(&body); err != nil { log.Fatal(err) } if res.StatusCode != http.StatusOK { log.Fatalf("%s %s: %s", res.Status, body.Code, body.Detail) } fmt.Println(gtin, serial, body.PassportID, body.Status, res.Request.URL) } ``` ## The linkset With `?linkType=all`, or with `Accept: application/json` (also `application/linkset+json` or `application/ld+json`) and no `linkType`, the resolver answers `200` with all links of the passport: ```json title="200" { "linkset": [ { "anchor": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184", "https://gs1.org/voc/pip": [ { "href": "https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "type": "text/html", "title": "Battery passport" } ], "describedby": [ { "href": "https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "type": "application/json", "title": "Public passport (JSON)" } ] } ] } ``` ## Look up by GTIN and serial number If you know the BatteryPass address and have GTIN and serial number from another source (a label, a delivery note, an ERP record), use [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id). The path parameter is `{gtin}:{serial}`. The GTIN may have 8, 12, 13 or 14 digits and must have a valid check digit; percent-encode the serial number. ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/public/dpps/by-product-id/04012345000023:SV2027-000184" ``` The answer is the same public JSON as above. A malformed value is `400 validation_error` on `productId` (`invalid_format`); an unknown pair is `404 not_found`. ## Errors and limits | Answer | Meaning | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `404` from the resolver | No passport for this GTIN and serial number: a Problem `not_found` for JSON clients, a short page for browsers. | | `200` without `document` | The passport exists but is not public right now; `status` says why. | | `400 validation_error` | `getPublicPassportByProductId` with a value that is not `{gtin}:{serial}` with a valid GTIN. | | `429 rate_limited` | More than 300 public requests per minute from your address (resolver and public reads together); wait for `Retry-After`. | Resolver answers and the current public JSON may be cached for five minutes. If you scan many codes, cache the results on your side as well. --- # Handle API keys securely > Where keys belong, which scope each system gets, how to rotate without downtime and what to do when a key leaks. An API key (`bp_live_…`) belongs to your organisation, not to a person. Whoever holds it reads what a viewer of your organisation reads, including restricted passport data and exports, and with the scope `condition` reports measurements and events in your organisation's name. Treat every key like a password. How keys work is described in [Authentication](https://passport.kabin.sh/docs/en/authentication); the wider picture in [Security and data protection](https://passport.kabin.sh/docs/en/general/security). Owners create and revoke keys in the portal under **Settings → API keys**. There is no API for it, and a key's secret is shown only once, when it is created. ## Rules | Rule | Why | | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | Keep keys in a secrets manager (or your platform's secret store) and hand them to the process as an environment variable | They stay out of code, images and backups | | Never put a key into source code, a repository, a container image, a URL, a log line, a ticket or a chat | Each of these is copied, indexed or kept for years | | Never ship a key to a browser, a mobile app or a device in the field | Anything on a client can be read; public passport reads need no key at all | | Send keys only in the `Authorization: Bearer` header, only over HTTPS | Headers do not end up in access logs and browser histories the way URLs do | | One key per system | You can revoke one system without stopping the others, and the portal shows when each key was last used | | The smallest scope that works | A leaked read key cannot report data; a leaked condition key cannot read your data | ## Which scope for which system | System | Scope | | ------------------------------------------------------------- | -------------------------------------------- | | ERP or MES that copies units and passports | `read` | | Data warehouse or reporting | `read` | | Battery management system or gateway that sends measurements | `condition` | | Service partner's system that reports inspections and repairs | `condition` | | A tool that looks up condition fields before it sends | `read` and `condition`, or two separate keys | | Middleware that stages units from production as an import | `import` (members commit and approve) | A key is not limited to some models or units: a `condition` key you hand to a service partner can report for every unit of your organisation. Hand it only to partners you would let do that. ## Load the key at start Read the key from the environment once, fail early when it is missing, and never print it: ```bash title="Shell" # In a systemd unit, a CI secret or your orchestrator's secret store, not in a script under version control export BATTERYPASS_API_KEY="$(cat /run/secrets/batterypass_api_key)" ``` ```ts title="TypeScript" const key = process.env.BATTERYPASS_API_KEY; if (!key?.startsWith('bp_live_')) throw new Error('BATTERYPASS_API_KEY is missing or malformed'); const headers = { Authorization: `Bearer ${key}` }; // pass this object along; never log it ``` ```python title="Python" import os import sys key = os.environ.get("BATTERYPASS_API_KEY", "") if not key.startswith("bp_live_"): sys.exit("BATTERYPASS_API_KEY is missing or malformed") headers = {"Authorization": f"Bearer {key}"} # pass this dict along; never log it ``` ```go title="Go" package main import ( "log" "net/http" "os" "strings" ) func main() { key := os.Getenv("BATTERYPASS_API_KEY") if !strings.HasPrefix(key, "bp_live_") { log.Fatal("BATTERYPASS_API_KEY is missing or malformed") } req, _ := http.NewRequest(http.MethodGet, os.Getenv("BATTERYPASS_URL")+"/api/v1/registry", nil) req.Header.Set("Authorization", "Bearer "+key) // never log req.Header res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } res.Body.Close() log.Println("getRegistryInfo:", res.Status) } ``` If your HTTP client or proxy logs requests, make sure it masks the `Authorization` header. ## Rotate without downtime Keys do not expire. Rotate them on a schedule you choose, and whenever someone who knew a key leaves. An organisation can have 25 active keys, so old and new key fit side by side during the switch. 1. **Create the new key** An owner creates a key with the same scopes and a name that tells the two apart, for example `ERP sync 2026-10`, and puts the secret straight into the secrets manager. 2. **Deploy it** Point the system at the new secret and restart or reload it. Both keys work at the same time, so nothing fails in between. 3. **Check that the new key is used** In **Settings → API keys**, the new key shows a recent **Last used** time and the old one stops changing. The time is updated at most once a minute. 4. **Revoke the old key** Revoke the old key. Revocation is immediate and final; the key stays in the list as revoked. ## When a key leaks A key leaked when it appeared anywhere outside your secrets manager: a commit, a log, a screenshot, a support ticket, a former employee's notes. 1. **Revoke it now** An owner revokes the key in **Settings → API keys**. From that moment every request with it is `401 unauthenticated`. Do this first, before you know whether the key was misused. 2. **Replace it** Create a new key with the same scopes and deploy it as in the rotation above. The system that used the old key is down until then, which is the price of revoking first. 3. **Check what happened** Writes made with a key are recorded with the key as the actor: look at the portal's audit log and at lifecycle events with `source: api` and the key's name in `api_key`. Reads are not recorded, so assume that everything the key could read was read. 4. **Remove the copy** Delete the key from where it leaked (rewrite the commit, purge the log, edit the ticket), even though it no longer works, so that scanners stop flagging it. > [!TIP] > **Find keys before others do** > > Secrets have a fixed shape: `bp_live_` followed by 43 base64url characters (letters, digits, `-` and `_`). Add the pattern > `bp_live_[A-Za-z0-9_-]{43}` to your secret scanner and pre-commit hooks. The portal shows the first 12 characters > of each key, enough to match a finding to a key without exposing the secret. All keys of your organisation share one limit of 600 requests per minute, so a leaked key that is still active can also slow down your legitimate integrations ([Rate limits](https://passport.kabin.sh/docs/en/rate-limits)). --- # Connect AI tools with MCP > Use the BatteryPass integration API from Claude Code, Cursor, VS Code and other MCP clients with an organisation API key. BatteryPass runs a **Model Context Protocol (MCP) server** at `https:///mcp`. An AI tool that speaks MCP connects to it with an API key of your organisation and gets one tool for each operation of the integration API: it can look up battery models, units, values and their completeness, read passports and their versions, follow the change feed, export BatteryPass JSON, PDFs and QR codes, and, with the right scope, report condition data and lifecycle events or map and validate spreadsheet imports. Use it when you want an assistant to answer questions about your data ("Which units of model X still have open fields?") or to prepare and send condition reports on your behalf. For scheduled, unattended integrations call the REST API directly; the tools are the same operations. > [!NOTE] > The MCP server is part of every BatteryPass installation and needs no extra service. It speaks the Streamable HTTP > transport (stateless, protocol version 2026-07-28; older clients that use the `initialize` handshake also work). ## Before you start 1. **Create an API key** An owner of your organisation opens **Settings → API keys** and creates a key for this one tool. Choose the scopes the tool needs: `read` to read data, `condition` to report condition data and lifecycle events, `import` to map, validate and read spreadsheet imports. The page also shows the setup snippets below with your server's address. 2. **Provide the key as an environment variable** The key is shown once: keep it in your password manager. The configurations below read it from the environment variable `BATTERYPASS_API_KEY` when the tool starts, so set the variable in the environment your tool starts from, for example in the current shell: ```bash title="macOS and Linux" export BATTERYPASS_API_KEY="bp_live_…" ``` ```powershell title="Windows (PowerShell)" $env:BATTERYPASS_API_KEY = "bp_live_…" ``` 3. **Add the server to your tool** Follow the tab for your tool below, then ask it to list the BatteryPass tools. ## Set up your tool **Claude Code** Add the server to your project's `.mcp.json`. Claude Code replaces `${BATTERYPASS_API_KEY}` from the environment each time it starts, so neither this file nor Claude Code's configuration holds the key, and you can share the file with your team: ```json title=".mcp.json" { "mcpServers": { "batterypass": { "type": "http", "url": "https://passport.example.com/mcp", "headers": { "Authorization": "Bearer ${BATTERYPASS_API_KEY}" } } } } ``` To add the server for your user in all projects instead, use the CLI with `--scope user` (without it, the server is added for the current project on your machine only): ```bash title="Terminal" claude mcp add --transport http --scope user batterypass https://passport.example.com/mcp \ --header "Authorization: Bearer bp_live_…" ``` > [!WARNING] > The CLI saves the header, key included, in plain text in Claude Code's configuration (`~/.claude.json`), and > your shell may keep the command in its history. Writing `$BATTERYPASS_API_KEY` in the command does not avoid > this: the shell replaces it before Claude Code sees it (and PowerShell needs `$env:BATTERYPASS_API_KEY`). Use > `.mcp.json` to keep the key out of files. Check the connection with `claude mcp list` or `/mcp` inside Claude Code. **Cursor** ```json title=".cursor/mcp.json" { "mcpServers": { "batterypass": { "url": "https://passport.example.com/mcp", "headers": { "Authorization": "Bearer ${env:BATTERYPASS_API_KEY}" } } } } ``` Restart Cursor after you set the environment variable; the server then appears under **Settings → MCP**. **VS Code** VS Code asks for the key the first time it starts the server and keeps it in its secret storage: ```json title=".vscode/mcp.json" { "inputs": [ { "type": "promptString", "id": "batterypass-api-key", "description": "BatteryPass API key", "password": true } ], "servers": { "batterypass": { "type": "http", "url": "https://passport.example.com/mcp", "headers": { "Authorization": "Bearer ${input:batterypass-api-key}" } } } } ``` **Other clients** A client that only starts local (stdio) servers can reach BatteryPass through the `mcp-remote` bridge. Put the whole header into an environment variable, because some clients split arguments at spaces: ```json title="Client configuration" { "mcpServers": { "batterypass": { "command": "npx", "args": ["-y", "mcp-remote@0.15.2", "https://passport.example.com/mcp", "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer bp_live_…" } } } } ``` > [!NOTE] > Connectors in claude.ai, Claude Desktop and ChatGPT that can only sign in with OAuth cannot connect yet: the MCP > server accepts API keys only. Use Claude Code, Cursor, VS Code or `mcp-remote` instead. ## The tools Every operation of the published integration API is one tool, except the file upload `createImport`. Its name is the operation's `operationId` in snake_case, so `listBatteryModels` in the API reference is the tool `list_battery_models`. The tool's arguments are the operation's path and query parameters under their names and the JSON request body under `body`; the descriptions come from the API reference and name the scope a tool needs. | Scope | Tools | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `read` | Catalogue: `list_battery_models`, `get_battery_model`, `list_model_versions`, `get_model_version`, `list_batteries`, `get_battery`, `export_units`, `list_facilities` | | `read` | Values and completeness: `list_model_version_values`, `list_model_version_value_history`, `get_model_version_completeness`, `list_battery_values`, `list_battery_value_history`, `get_battery_completeness` | | `read` | Passports: `get_passport`, `list_passport_versions`, `get_passport_version`, `get_passport_version_diff`, `export_passport_json`, `export_passport_pdf`, `get_passport_qr`, `get_registry_submission_status` | | `read` | Lifecycle, documents and registry: `list_lifecycle_events`, `get_condition`, `list_evidence`, `get_evidence`, `download_evidence`, `get_registry_info`, `get_registry_schema`, `get_registry_status` | | `read` | Change feed: `list_changes` | | `condition` | `update_condition` (report measured condition data), `create_lifecycle_event` | | `import` | `list_imports`, `get_import`, `update_import_mapping`, `validate_import` | | none (public) | `get_public_passport`, `get_public_passport_version`, `get_public_passport_by_product_id`, `verify_passport`, `download_public_passport_document` | A tool only sees the data of the organisation that owns the key, exactly like a REST request with that key. Your tool lists only the tools of the key's scopes; public passport reads are available to every key. A tool cannot carry a file, so a spreadsheet is uploaded over REST with [`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import) and the same key; the assistant then maps and validates the import with the tools ([Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production)). Committing the import and approving its values stay with members in the portal. How results come back: - JSON responses are returned as text and as structured content. - Lists return one page. The tool descriptions ask the assistant to pass a small `limit` and to call again with `cursor` set to `next_cursor` until it is null. - Files (PDF, QR codes as PNG or SVG, spreadsheets, documents) are returned as embedded resources up to 1 MiB. Larger files fail with `result_too_large`; download them over REST with the key instead. - An error of the API is a tool error with the Problem's `code`, `title`, `detail`, the field `errors` and the `request_id`, for example `not_found` or `validation_error`. Arguments that do not match the tool's schema fail with `invalid_arguments` before anything is sent. > [!TIP] > Writes are only the two `condition` tools and `update_import_mapping` and `validate_import`. They are recorded in > your audit log with the key as actor and the note `channel: mcp`. After a condition report a published passport may > be re-published right away, depending on your organisation's setting. Let your tool ask before it calls them. ## Security - **One key per tool and person.** You can then see in the audit log who acted, and revoke one tool without touching the others. - **Least scopes.** Give `condition` only to tools that must report measurements and `import` only to tools that prepare imports; a `read` key cannot change anything. - **Revoke** a key on the API keys page as soon as a tool or laptop is no longer used. Revoking works at once. - **Results are data, not instructions.** Values, notes and documents in BatteryPass come from your team, your suppliers and service partners. The server tells the assistant to treat them as data; still review what an assistant proposes to write. - Never put a key into a repository, a URL or a chat message. The configuration files above read it from the environment; only the `claude mcp add` command and the `mcp-remote` configuration hold it in plain text. ## Troubleshooting | Answer | Cause and fix | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401 unauthenticated` | No key, a mistyped key or a revoked key: the answer is the same for all three. Check that the environment variable is set in the environment the tool starts from, and that the header reads `Authorization: Bearer bp_live_…`. | | `403 forbidden` on connecting | A browser-based client on another web origin sent an `Origin` header. Browser clients on other origins are not supported; use a desktop client or the `mcp-remote` bridge. | | `400` with the JSON-RPC error `-32600` | The client sent a batch (several messages in one request). Send one message per request; current clients do. | | Tool error `403 forbidden` | The tool needs a scope your key does not have, for example `condition`. Create a key with that scope. | | `405 method not allowed` | Something sent a `GET` (or `DELETE`) to `/mcp`. The server answers `POST` only; update the client or choose the Streamable HTTP transport (`--transport http`). | | `404 not found` | The address is wrong, or the server's administrator turned MCP off (`MCP_ENABLED=false`). | | `429 rate_limited` or tool error `rate_limited` | All keys of your organisation together may make 600 requests per minute, and each tool call counts twice (the MCP request and the API request it makes). Wait the `Retry-After` seconds (`retry_after_seconds` in tool errors). Repeated wrong keys from one address are limited to 30 attempts per 15 minutes. | [**API reference**](https://passport.kabin.sh/docs/en/api-reference): Every operation behind the tools, with parameters, responses and error codes. [**API keys**](https://passport.kabin.sh/settings/api-keys): Create and revoke keys in the BatteryPass portal (owners only). --- # Recipes > Short, runnable programs for common integration jobs, each in cURL, TypeScript, Python and Go. A recipe is one small program that does one job end to end. Copy it, set `BATTERYPASS_URL` and `BATTERYPASS_API_KEY` (see the [Quickstart](https://passport.kabin.sh/docs/en/quickstart)), replace the placeholder where it reads your own data, and run it. Each recipe comes as a shell script with `curl` and `jq`, and in TypeScript (Node 20+), Python 3 with `requests` and Go with the standard library. [**Nightly condition push**](https://passport.kabin.sh/docs/en/recipes/nightly-condition-push): Send last night's measurements of every unit with `updateCondition`, with backoff on `429`. [**QR labels for a batch**](https://passport.kabin.sh/docs/en/recipes/qr-labels-for-a-batch): Download an SVG QR code for every published unit of a production batch. [**Data warehouse sync**](https://passport.kabin.sh/docs/en/recipes/data-warehouse-sync): Write every unit with its current values to a JSON Lines file. [**Verify signatures**](https://passport.kabin.sh/docs/en/recipes/verify-signatures): Check the signatures of a list of public passports offline. [**Published passports of a model**](https://passport.kabin.sh/docs/en/recipes/published-passports-of-a-model): List passport id, version and public link of every published unit of a model as CSV. The [Guides](https://passport.kabin.sh/docs/en/guides) explain the operations behind these recipes in more depth, including the errors to expect. --- # Nightly condition push > A scheduled job that reads last night's measurements from your own source and sends one updateCondition per unit, with backoff on 429. The job reads measurements from your own source, a placeholder here, and sends one [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition) per unit. It needs a key with the scope `condition`. Run it once a night with cron, a systemd timer or your scheduler. - On `429` or `5xx` it waits (`Retry-After`, otherwise exponential backoff) and sends the same body again. That is safe: values that are already stored come back in `skipped` as `unchanged`. - Other errors (for example `400 validation_error` for a value out of range) are logged per unit, the job carries on with the next one and exits with status 1 at the end. - Put the time of the measurement into `measured_at`, not the time the job runs. ```bash title="cURL" #!/usr/bin/env bash # nightly-condition-push.sh: one updateCondition per line of readings.jsonl (needs curl and jq) # Each line, written by your BMS export: {"battery_id": "…", "measured_at": "…", "values": {"condition.…": 81.5}} set -uo pipefail failed=0 while IFS= read -r line; do id=$(jq -r .battery_id <<<"$line") body=$(jq -c '{measured_at, note: "Nightly BMS export", values: (.values | to_entries | map({field_key: .key, value: .value}))}' <<<"$line") # --retry repeats 429 and 5xx answers and waits as long as Retry-After says status=$(curl -sS --retry 5 -o response.json -w '%{http_code}' -X PATCH \ "$BATTERYPASS_URL/api/v1/batteries/$id/condition" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ -H "Content-Type: application/json" \ --data "$body") if [ "$status" = 200 ]; then jq -c --arg id "$id" '{id: $id, accepted: (.accepted | length), skipped, outcome: .publication.outcome}' response.json else failed=1 jq -c --arg id "$id" --arg status "$status" '{id: $id, status: $status, code, request_id}' response.json >&2 fi done < readings.jsonl exit $failed ``` ```ts title="TypeScript" // nightly-condition-push.mts: npx tsx nightly-condition-push.mts const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const KEY = process.env.BATTERYPASS_API_KEY; // a key with the scope "condition" const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); type Reading = { batteryId: string; measuredAt: string; values: Record }; // Placeholder: read last night's measurements from your BMS, historian or database. async function readMeasurements(): Promise { return [ { batteryId: '0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f', measuredAt: '2026-10-24T02:00:00Z', values: { 'condition.state_of_charge': 81.5, 'condition.number_of_full_cycles': 412 }, }, ]; } async function push(r: Reading): Promise { const body = JSON.stringify({ measured_at: r.measuredAt, note: 'Nightly BMS export', values: Object.entries(r.values).map(([field_key, value]) => ({ field_key, value })), }); for (let attempt = 1; attempt <= 6; attempt++) { const res = await fetch(`${BASE}/batteries/${r.batteryId}/condition`, { method: 'PATCH', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body, }); if (res.status === 429 || res.status >= 500) { // Sending the same readings again is safe: values already stored come back as skipped. const retryAfter = Number(res.headers.get('Retry-After')); await sleep(retryAfter > 0 ? retryAfter * 1000 : Math.random() * 1000 * 2 ** attempt); continue; } const result = await res.json(); if (!res.ok) throw new Error(`${res.status} ${result.code} (request_id ${result.request_id})`); return `accepted ${result.accepted.length}, skipped ${result.skipped.length}, ${result.publication.outcome}`; } throw new Error('gave up after 6 attempts'); } let failed = 0; for (const reading of await readMeasurements()) { try { console.log(reading.batteryId, await push(reading)); } catch (err) { failed++; console.error(reading.batteryId, String(err)); } } process.exitCode = failed > 0 ? 1 : 0; ``` ```python title="Python" # nightly_condition_push.py import os import random import sys import time import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"] # scope "condition" def read_measurements(): """Placeholder: read last night's measurements from your BMS, historian or database.""" return [ { "battery_id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f", "measured_at": "2026-10-24T02:00:00Z", "values": {"condition.state_of_charge": 81.5, "condition.number_of_full_cycles": 412}, }, ] def push(reading): body = { "measured_at": reading["measured_at"], "note": "Nightly BMS export", "values": [{"field_key": k, "value": v} for k, v in reading["values"].items()], } for attempt in range(1, 7): res = session.patch(f"{BASE}/batteries/{reading['battery_id']}/condition", json=body, timeout=30) if res.status_code == 429 or res.status_code >= 500: # Sending the same readings again is safe: values already stored come back as skipped. retry_after = res.headers.get("Retry-After") time.sleep(int(retry_after) if retry_after else random.uniform(0, 2**attempt)) continue result = res.json() if not res.ok: raise RuntimeError(f"{res.status_code} {result['code']} (request_id {result.get('request_id')})") return f"accepted {len(result['accepted'])}, skipped {len(result['skipped'])}, {result['publication']['outcome']}" raise RuntimeError("gave up after 6 attempts") failed = 0 for reading in read_measurements(): try: print(reading["battery_id"], push(reading)) except Exception as err: failed += 1 print(reading["battery_id"], err, file=sys.stderr) sys.exit(1 if failed else 0) ``` ```go title="Go" // nightly-condition-push: go run . package main import ( "bytes" "encoding/json" "errors" "fmt" "log" "math/rand/v2" "net/http" "os" "strconv" "time" ) var ( base = os.Getenv("BATTERYPASS_URL") + "/api/v1" key = os.Getenv("BATTERYPASS_API_KEY") // a key with the scope "condition" ) type reading struct { BatteryID string MeasuredAt string Values map[string]any } // readMeasurements is a placeholder: read last night's measurements from your BMS, historian or database. func readMeasurements() []reading { return []reading{{ BatteryID: "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f", MeasuredAt: "2026-10-24T02:00:00Z", Values: map[string]any{"condition.state_of_charge": 81.5, "condition.number_of_full_cycles": 412}, }} } type result struct { Accepted []json.RawMessage `json:"accepted"` Skipped []json.RawMessage `json:"skipped"` Publication struct { Outcome string `json:"outcome"` } `json:"publication"` Code string `json:"code"` RequestID string `json:"request_id"` } func push(r reading) (string, error) { values := []map[string]any{} for k, v := range r.Values { values = append(values, map[string]any{"field_key": k, "value": v}) } body, err := json.Marshal(map[string]any{"measured_at": r.MeasuredAt, "note": "Nightly BMS export", "values": values}) if err != nil { return "", err } for attempt := 1; attempt <= 6; attempt++ { req, err := http.NewRequest(http.MethodPatch, base+"/batteries/"+r.BatteryID+"/condition", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return "", err } var out result _ = json.NewDecoder(res.Body).Decode(&out) res.Body.Close() if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 { // Sending the same readings again is safe: values already stored come back as skipped. wait := time.Duration(rand.Int64N(int64(time.Second << attempt))) if s, _ := strconv.Atoi(res.Header.Get("Retry-After")); s > 0 { wait = time.Duration(s) * time.Second } time.Sleep(wait) continue } if res.StatusCode != http.StatusOK { return "", fmt.Errorf("%d %s (request_id %s)", res.StatusCode, out.Code, out.RequestID) } return fmt.Sprintf("accepted %d, skipped %d, %s", len(out.Accepted), len(out.Skipped), out.Publication.Outcome), nil } return "", errors.New("gave up after 6 attempts") } func main() { failed := 0 for _, r := range readMeasurements() { summary, err := push(r) if err != nil { failed++ log.Println(r.BatteryID, err) continue } fmt.Println(r.BatteryID, summary) } if failed > 0 { os.Exit(1) } } ``` ```text title="Output" 0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f accepted 1, skipped 1, published ``` The shell version reads `readings.jsonl`, one JSON object per unit as shown in its header. Fields, units of measure and the rules for `measured_at` are explained in [Push condition data](https://passport.kabin.sh/docs/en/guides/push-condition-data). With many units, keep in mind that all keys of your organisation share 600 requests per minute. --- # QR labels for a production batch > List the published units of a batch by serial number prefix and save the QR code of each one as an SVG file for label printing. The script lists the units whose serial number starts with a prefix (your batch, for example `SV2027-0001`) and whose passport is published, with [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries), then saves the QR code of each unit with [`getPassportQr`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr) as `labels/.svg`. It needs a key with the scope `read`. - Other ways to select a batch: `import_id` (the units of one spreadsheet import) or `model_version_id`. Replace `serial_prefix` in the query. - Labels printed before publication: use `passport_status=qr_locked` instead. The QR code exists as soon as the identifiers are locked, and the link inside it never changes afterwards. - SVG scales for any label size. For a bitmap, request `?format=png&size=1024` (128 to 2048 pixels). - A `/` in a serial number becomes `_` in the file name. ```bash title="cURL" #!/usr/bin/env bash # qr-labels.sh SV2027-0001: an SVG QR code per published unit of a batch (needs curl and jq) set -euo pipefail prefix="$1" # the batch: serial numbers that start with this, e.g. SV2027-0001 auth="Authorization: Bearer $BATTERYPASS_API_KEY" mkdir -p labels cursor="" while :; do page=$(curl -sS --fail --retry 5 -G "$BATTERYPASS_URL/api/v1/batteries" -H "$auth" \ --data-urlencode "serial_prefix=$prefix" -d passport_status=published -d limit=200 \ ${cursor:+--data-urlencode "cursor=$cursor"}) jq -r '.items[] | [.id, (.serial_number | gsub("/"; "_"))] | @tsv' <<<"$page" | while IFS=$'\t' read -r id serial; do curl -sS --fail --retry 5 -H "$auth" -o "labels/$serial.svg" \ "$BATTERYPASS_URL/api/v1/batteries/$id/passport/qr?format=svg" echo "labels/$serial.svg" done cursor=$(jq -r '.next_cursor // empty' <<<"$page") [ -n "$cursor" ] || break done ``` ```ts title="TypeScript" // qr-labels.mts: npx tsx qr-labels.mts SV2027-0001 import { mkdir, writeFile } from 'node:fs/promises'; const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }; const prefix = process.argv[2]; // the batch: serial numbers that start with this, e.g. SV2027-0001 const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function get(path: string): Promise { for (let attempt = 1; ; attempt++) { const res = await fetch(BASE + path, { headers }); if (res.ok) return res; if (res.status !== 429 || attempt === 5) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`); await sleep(Number(res.headers.get('Retry-After') ?? 1) * 1000); } } await mkdir('labels', { recursive: true }); let cursor: string | null = null; do { const query = new URLSearchParams({ serial_prefix: prefix, passport_status: 'published', limit: '200' }); if (cursor) query.set('cursor', cursor); const page = await (await get(`/batteries?${query}`)).json(); for (const unit of page.items) { const svg = await (await get(`/batteries/${unit.id}/passport/qr?format=svg`)).arrayBuffer(); const file = `labels/${unit.serial_number.replaceAll('/', '_')}.svg`; await writeFile(file, Buffer.from(svg)); console.log(file); } cursor = page.next_cursor; } while (cursor); ``` ```python title="Python" # qr_labels.py: python qr_labels.py SV2027-0001 import os import sys import time import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"] prefix = sys.argv[1] # the batch: serial numbers that start with this, e.g. SV2027-0001 def get(path, **params): for attempt in range(1, 6): res = session.get(BASE + path, params=params, timeout=30) if res.status_code != 429 or attempt == 5: res.raise_for_status() return res time.sleep(int(res.headers.get("Retry-After", "1"))) os.makedirs("labels", exist_ok=True) cursor = None while True: params = {"serial_prefix": prefix, "passport_status": "published", "limit": 200} if cursor: params["cursor"] = cursor page = get("/batteries", **params).json() for unit in page["items"]: svg = get(f"/batteries/{unit['id']}/passport/qr", format="svg").content path = os.path.join("labels", unit["serial_number"].replace("/", "_") + ".svg") with open(path, "wb") as f: f.write(svg) print(path) cursor = page["next_cursor"] if not cursor: break ``` ```go title="Go" // qr-labels: go run . SV2027-0001 package main import ( "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" ) var ( base = os.Getenv("BATTERYPASS_URL") + "/api/v1" key = os.Getenv("BATTERYPASS_API_KEY") ) // get returns the response body of a GET request and waits out 429 answers. func get(path string) ([]byte, error) { for attempt := 1; ; attempt++ { req, err := http.NewRequest(http.MethodGet, base+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } body, err := io.ReadAll(res.Body) res.Body.Close() switch { case err != nil: return nil, err case res.StatusCode == http.StatusOK: return body, nil case res.StatusCode != http.StatusTooManyRequests || attempt == 5: return nil, fmt.Errorf("%s: %s %s", path, res.Status, body) } wait, _ := strconv.Atoi(res.Header.Get("Retry-After")) time.Sleep(time.Duration(max(wait, 1)) * time.Second) } } func main() { prefix := os.Args[1] // the batch: serial numbers that start with this, e.g. SV2027-0001 if err := os.MkdirAll("labels", 0o755); err != nil { log.Fatal(err) } cursor := "" for { q := url.Values{"serial_prefix": {prefix}, "passport_status": {"published"}, "limit": {"200"}} if cursor != "" { q.Set("cursor", cursor) } data, err := get("/batteries?" + q.Encode()) if err != nil { log.Fatal(err) } var page struct { Items []struct { ID string `json:"id"` SerialNumber string `json:"serial_number"` } `json:"items"` NextCursor *string `json:"next_cursor"` } if err := json.Unmarshal(data, &page); err != nil { log.Fatal(err) } for _, unit := range page.Items { svg, err := get("/batteries/" + unit.ID + "/passport/qr?format=svg") if err != nil { log.Fatal(err) } file := filepath.Join("labels", strings.ReplaceAll(unit.SerialNumber, "/", "_")+".svg") if err := os.WriteFile(file, svg, 0o644); err != nil { log.Fatal(err) } fmt.Println(file) } if page.NextCursor == nil { break } cursor = *page.NextCursor } } ``` ```text title="Output" labels/SV2027-000182.svg labels/SV2027-000184.svg labels/SV2027-000185.svg ``` Each unit costs one request, and all keys of your organisation share 600 requests per minute; the scripts wait when they get `429`. More on the QR code and the other exports in [Export passports](https://passport.kabin.sh/docs/en/guides/export-passports). --- # Data warehouse sync > Page through all units and their current values and write one JSON line per unit, ready to load into a data warehouse. The script pages through all units with [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries), reads the current values of each one with [`listBatteryValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-values) and writes `units.jsonl`: one line per unit with `synced_at`, the `unit` and its `values`. Most warehouses load JSON Lines directly. It needs a key with the scope `read`. - `values` holds the current value of every unit-level field that has (or had) a value, with its provenance: `revision`, `source`, `review_status`, `recorded_at` and, for condition data, `measured_at`. A cleared field has `value: null`. - Model-level data (nominal voltage, rated capacity and so on) belongs to the model version, not the unit: read it once per version with [`listModelVersionValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-model-version-values). - The script makes one request per unit plus one per 200 units. With 10 000 units that is about 17 minutes at the shared limit of 600 requests per minute; it waits when it gets `429`. ```bash title="cURL" #!/usr/bin/env bash # warehouse-sync.sh: every unit with its current values as one JSON line (needs curl and jq) set -euo pipefail auth="Authorization: Bearer $BATTERYPASS_API_KEY" synced_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) cursor="" : > units.jsonl while :; do page=$(curl -sS --fail --retry 5 -G "$BATTERYPASS_URL/api/v1/batteries" -H "$auth" \ -d limit=200 ${cursor:+--data-urlencode "cursor=$cursor"}) jq -c '.items[]' <<<"$page" | while IFS= read -r unit; do id=$(jq -r .id <<<"$unit") curl -sS --fail --retry 5 -H "$auth" "$BATTERYPASS_URL/api/v1/batteries/$id/values" | jq -c --argjson unit "$unit" --arg at "$synced_at" '{synced_at: $at, unit: $unit, values: .items}' >> units.jsonl done cursor=$(jq -r '.next_cursor // empty' <<<"$page") [ -n "$cursor" ] || break done wc -l < units.jsonl ``` ```ts title="TypeScript" // warehouse-sync.mts: npx tsx warehouse-sync.mts import { createWriteStream } from 'node:fs'; const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function get(path: string) { for (let attempt = 1; ; attempt++) { const res = await fetch(BASE + path, { headers }); if (res.ok) return res.json(); if (res.status !== 429 || attempt === 5) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`); await sleep(Number(res.headers.get('Retry-After') ?? 1) * 1000); } } const out = createWriteStream('units.jsonl'); const syncedAt = new Date().toISOString(); let cursor: string | null = null; let count = 0; do { const page = await get(`/batteries?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`); for (const unit of page.items) { const values = await get(`/batteries/${unit.id}/values`); out.write(JSON.stringify({ synced_at: syncedAt, unit, values: values.items }) + '\n'); count++; } cursor = page.next_cursor; } while (cursor); out.end(); console.log(`${count} units written to units.jsonl`); ``` ```python title="Python" # warehouse_sync.py import json import os import time from datetime import datetime, timezone import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"] def get(path, **params): for attempt in range(1, 6): res = session.get(BASE + path, params=params, timeout=30) if res.status_code != 429 or attempt == 5: res.raise_for_status() return res.json() time.sleep(int(res.headers.get("Retry-After", "1"))) synced_at = datetime.now(timezone.utc).isoformat() count, cursor = 0, None with open("units.jsonl", "w", encoding="utf-8") as out: while True: page = get("/batteries", limit=200, **({"cursor": cursor} if cursor else {})) for unit in page["items"]: values = get(f"/batteries/{unit['id']}/values") out.write(json.dumps({"synced_at": synced_at, "unit": unit, "values": values["items"]}) + "\n") count += 1 cursor = page["next_cursor"] if not cursor: break print(f"{count} units written to units.jsonl") ``` ```go title="Go" // warehouse-sync: go run . package main import ( "bufio" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "strconv" "time" ) var ( base = os.Getenv("BATTERYPASS_URL") + "/api/v1" key = os.Getenv("BATTERYPASS_API_KEY") ) // get decodes the JSON answer of a GET request into out and waits out 429 answers. func get(path string, out any) error { for attempt := 1; ; attempt++ { req, err := http.NewRequest(http.MethodGet, base+path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) res, err := http.DefaultClient.Do(req) if err != nil { return err } if res.StatusCode == http.StatusOK { defer res.Body.Close() return json.NewDecoder(res.Body).Decode(out) } res.Body.Close() if res.StatusCode != http.StatusTooManyRequests || attempt == 5 { return fmt.Errorf("%s: %s", path, res.Status) } wait, _ := strconv.Atoi(res.Header.Get("Retry-After")) time.Sleep(time.Duration(max(wait, 1)) * time.Second) } } func main() { file, err := os.Create("units.jsonl") if err != nil { log.Fatal(err) } defer file.Close() out := bufio.NewWriter(file) defer out.Flush() enc := json.NewEncoder(out) // one JSON document per line syncedAt := time.Now().UTC().Format(time.RFC3339) count, cursor := 0, "" for { path := "/batteries?limit=200" if cursor != "" { path += "&cursor=" + url.QueryEscape(cursor) } var page struct { Items []map[string]any `json:"items"` NextCursor *string `json:"next_cursor"` } if err := get(path, &page); err != nil { log.Fatal(err) } for _, unit := range page.Items { var values struct { Items []map[string]any `json:"items"` } if err := get(fmt.Sprintf("/batteries/%s/values", unit["id"]), &values); err != nil { log.Fatal(err) } line := map[string]any{"synced_at": syncedAt, "unit": unit, "values": values.Items} if err := enc.Encode(line); err != nil { log.Fatal(err) } count++ } if page.NextCursor == nil { break } cursor = *page.NextCursor } fmt.Printf("%d units written to units.jsonl\n", count) } ``` ```json title="units.jsonl (one line, wrapped here)" {"synced_at":"2026-10-24T02:00:00Z", "unit":{"id":"0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f","serial_number":"SV2027-000184","passport_status":"published","row_version":4,"updated_at":"2026-07-02T10:15:00Z"}, "values":[{"field_key":"condition.state_of_charge","value":81.5,"unit":"%","revision":9,"source":"api","review_status":"approved","measured_at":"2026-10-24T06:00:00Z","recorded_at":"2026-10-24T06:00:03Z","registry_version":"0.5.0","checks":[]}]} ``` Load the file into a staging table and merge on `unit.id`. To read less on later runs, compare `row_version`, `passport_status` and the values' `revision` with what you stored, as described in [Sync units](https://passport.kabin.sh/docs/en/guides/sync-units). For a complete copy of everything your organisation holds, an admin can also download the full organisation export in the portal. --- # Verify signatures of several passports > Check the Ed25519 signatures of a list of public passports offline, loading the key set once, and print one result per passport. The script takes passport UUIDs (the part after `urn:dpp:`) as arguments, loads the key set from `/.well-known/batterypass/keys.json` once, and checks each passport as described in [Verify a passport signature offline](https://passport.kabin.sh/docs/en/guides/verify-signature): canonicalise `document` with RFC 8785, compare the SHA-256 with `verification.hash`, verify the Ed25519 signature with the key named by `verification.key_id`. It needs no API key. It exits with status 1 if any passport is not `valid`, so it fits a monitoring job or an incoming-goods check. The cURL tab uses [`verifyPassport`](https://passport.kabin.sh/docs/en/api-reference/public/verify-passport) instead: the server checks the signatures for you, which is quick but relies on the server's answer. ```bash title="cURL" #!/usr/bin/env bash # verify-many.sh ...: the server-side check with verifyPassport (needs curl and jq) for id in "$@"; do curl -sS "$BATTERYPASS_URL/api/v1/public/passports/$id/verify" | jq -r --arg id "$id" '"\($id)\t\(if .valid == true then "valid (version \(.version_no))" elif .code then .code else "INVALID" end)"' done ``` ```ts title="TypeScript" // verify-many.mts: npm install canonicalize, then: npx tsx verify-many.mts ... import { createHash, createPublicKey, verify, type KeyObject } from 'node:crypto'; import canonicalize from 'canonicalize'; const base = process.env.BATTERYPASS_URL; const ids = process.argv.slice(2); // passport UUIDs const getJSON = async (url: string) => { const res = await fetch(url); if (!res.ok) throw new Error(`${res.status}`); return res.json(); }; // Load the key set once. const keys = new Map(); for (const jwk of (await getJSON(`${base}/.well-known/batterypass/keys.json`)).keys) { keys.set(jwk.kid, createPublicKey({ key: jwk, format: 'jwk' })); } async function check(id: string): Promise { const { status, document, verification: v } = await getJSON(`${base}/api/v1/public/passports/${id}`); if (!document || !v) return `not checked (${status})`; const canonical = Buffer.from(canonicalize(document) ?? '', 'utf8'); if (`sha256:${createHash('sha256').update(canonical).digest('hex')}` !== v.hash) return 'HASH MISMATCH'; const key = keys.get(v.key_id); if (!key) return `UNKNOWN KEY ${v.key_id}`; return verify(null, canonical, key, Buffer.from(v.signature, 'base64url')) ? 'valid' : 'INVALID SIGNATURE'; } let bad = 0; for (const id of ids) { const result = await check(id).catch((err) => `error ${err.message}`); if (result !== 'valid') bad++; console.log(`${id}\t${result}`); } process.exitCode = bad > 0 ? 1 : 0; ``` ```python title="Python" # verify_many.py: pip install requests cryptography rfc8785, then: python verify_many.py ... import base64 import hashlib import os import sys import requests import rfc8785 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey base = os.environ["BATTERYPASS_URL"] def b64url(text): return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) def get_json(url): res = requests.get(url, timeout=10) res.raise_for_status() return res.json() # Load the key set once. keys = { jwk["kid"]: Ed25519PublicKey.from_public_bytes(b64url(jwk["x"])) for jwk in get_json(f"{base}/.well-known/batterypass/keys.json")["keys"] } def check(passport_id): passport = get_json(f"{base}/api/v1/public/passports/{passport_id}") document, v = passport.get("document"), passport.get("verification") if not document or not v: return f"not checked ({passport['status']})" canonical = rfc8785.dumps(document) if "sha256:" + hashlib.sha256(canonical).hexdigest() != v["hash"]: return "HASH MISMATCH" if v["key_id"] not in keys: return f"UNKNOWN KEY {v['key_id']}" try: keys[v["key_id"]].verify(b64url(v["signature"]), canonical) return "valid" except InvalidSignature: return "INVALID SIGNATURE" bad = 0 for passport_id in sys.argv[1:]: # passport UUIDs try: result = check(passport_id) except requests.RequestException as err: result = f"error {err}" bad += result != "valid" print(f"{passport_id}\t{result}") sys.exit(1 if bad else 0) ``` ```go title="Go" // verify-many: go get github.com/gowebpki/jcs, then: go run . ... package main import ( "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "github.com/gowebpki/jcs" ) var base = os.Getenv("BATTERYPASS_URL") func getJSON(url string, v any) error { res, err := http.Get(url) if err != nil { return err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return fmt.Errorf("%s", res.Status) } return json.NewDecoder(res.Body).Decode(v) } func check(id string, keys map[string]ed25519.PublicKey) string { var p struct { Status string `json:"status"` Document json.RawMessage `json:"document"` Verification *struct { Hash, Signature string KeyID string `json:"key_id"` } `json:"verification"` } if err := getJSON(base+"/api/v1/public/passports/"+id, &p); err != nil { return "error " + err.Error() } if p.Document == nil || p.Verification == nil { return "not checked (" + p.Status + ")" } canonical, err := jcs.Transform(p.Document) if err != nil { return "error " + err.Error() } sum := sha256.Sum256(canonical) if "sha256:"+hex.EncodeToString(sum[:]) != p.Verification.Hash { return "HASH MISMATCH" } pub, ok := keys[p.Verification.KeyID] if !ok { return "UNKNOWN KEY " + p.Verification.KeyID } sig, err := base64.RawURLEncoding.DecodeString(p.Verification.Signature) if err != nil || !ed25519.Verify(pub, canonical, sig) { return "INVALID SIGNATURE" } return "valid" } func main() { // Load the key set once. var set struct { Keys []struct{ Kid, X string } `json:"keys"` } if err := getJSON(base+"/.well-known/batterypass/keys.json", &set); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } keys := map[string]ed25519.PublicKey{} for _, k := range set.Keys { if x, err := base64.RawURLEncoding.DecodeString(k.X); err == nil && len(x) == ed25519.PublicKeySize { keys[k.Kid] = x } } bad := 0 for _, id := range os.Args[1:] { // passport UUIDs result := check(id, keys) if result != "valid" { bad++ } fmt.Printf("%s\t%s\n", id, result) } if bad > 0 { os.Exit(1) } } ``` ```text title="Output" 0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10 valid 0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b11 not checked (qr_locked) 0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b12 error 404 ``` `not checked` means the passport is not public right now (its `status` is shown), so there is no signed document. Public reads are limited to 300 requests per minute per client address; for long lists, add a pause or handle `429` as in [Handle errors and retries](https://passport.kabin.sh/docs/en/guides/handle-errors). To verify passports of another manufacturer, set `BATTERYPASS_URL` to the address their passports come from. --- # Published passports of a model > List every unit of a battery model with a published passport and print passport id, current version and public link as CSV. The script lists the units of one battery model whose passport is published, with [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries) and the filters `battery_model_id` and `passport_status=published`, then reads each passport with [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport) and prints one CSV row per unit: serial number, passport id, current version, publication time and public page. It needs a key with the scope `read`. - The model id comes from [`listBatteryModels`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-battery-models). - Passports in `update_needed` are online too (their current version stays public until they are published again). Run the script a second time with `passport_status=update_needed` to include them. - To narrow it to one model version, use `model_version_id` instead of `battery_model_id`. ```bash title="cURL" #!/usr/bin/env bash # model-passports.sh : the published passports of one battery model as CSV (needs curl and jq) set -euo pipefail model_id="$1" # the id from listBatteryModels auth="Authorization: Bearer $BATTERYPASS_API_KEY" echo "serial_number,passport_id,version_no,published_at,public_url" cursor="" while :; do page=$(curl -sS --fail --retry 5 -G "$BATTERYPASS_URL/api/v1/batteries" -H "$auth" \ -d "battery_model_id=$model_id" -d passport_status=published -d limit=200 \ ${cursor:+--data-urlencode "cursor=$cursor"}) for id in $(jq -r '.items[].id' <<<"$page"); do curl -sS --fail --retry 5 -H "$auth" "$BATTERYPASS_URL/api/v1/batteries/$id/passport" | jq -r '[.serial_number, .passport_id, .current_version.version_no, .current_version.published_at, .public_url] | @csv' done cursor=$(jq -r '.next_cursor // empty' <<<"$page") [ -n "$cursor" ] || break done ``` ```ts title="TypeScript" // model-passports.mts: npx tsx model-passports.mts const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` }; const modelId = process.argv[2]; // the id from listBatteryModels const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function get(path: string) { for (let attempt = 1; ; attempt++) { const res = await fetch(BASE + path, { headers }); if (res.ok) return res.json(); if (res.status !== 429 || attempt === 5) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`); await sleep(Number(res.headers.get('Retry-After') ?? 1) * 1000); } } console.log('serial_number,passport_id,version_no,published_at,public_url'); let cursor: string | null = null; do { const query = new URLSearchParams({ battery_model_id: modelId, passport_status: 'published', limit: '200' }); if (cursor) query.set('cursor', cursor); const page = await get(`/batteries?${query}`); for (const unit of page.items) { const p = await get(`/batteries/${unit.id}/passport`); const v = p.current_version; console.log([p.serial_number, p.passport_id, v?.version_no, v?.published_at, p.public_url].join(',')); } cursor = page.next_cursor; } while (cursor); ``` ```python title="Python" # model_passports.py: python model_passports.py import csv import os import sys import time import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"] model_id = sys.argv[1] # the id from listBatteryModels def get(path, **params): for attempt in range(1, 6): res = session.get(BASE + path, params=params, timeout=30) if res.status_code != 429 or attempt == 5: res.raise_for_status() return res.json() time.sleep(int(res.headers.get("Retry-After", "1"))) out = csv.writer(sys.stdout) out.writerow(["serial_number", "passport_id", "version_no", "published_at", "public_url"]) cursor = None while True: params = {"battery_model_id": model_id, "passport_status": "published", "limit": 200} if cursor: params["cursor"] = cursor page = get("/batteries", **params) for unit in page["items"]: p = get(f"/batteries/{unit['id']}/passport") v = p.get("current_version", {}) out.writerow([p["serial_number"], p.get("passport_id"), v.get("version_no"), v.get("published_at"), p.get("public_url")]) cursor = page["next_cursor"] if not cursor: break ``` ```go title="Go" // model-passports: go run . package main import ( "encoding/csv" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "strconv" "time" ) var ( base = os.Getenv("BATTERYPASS_URL") + "/api/v1" key = os.Getenv("BATTERYPASS_API_KEY") ) // get decodes the JSON answer of a GET request into out and waits out 429 answers. func get(path string, out any) error { for attempt := 1; ; attempt++ { req, err := http.NewRequest(http.MethodGet, base+path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+key) res, err := http.DefaultClient.Do(req) if err != nil { return err } if res.StatusCode == http.StatusOK { defer res.Body.Close() return json.NewDecoder(res.Body).Decode(out) } res.Body.Close() if res.StatusCode != http.StatusTooManyRequests || attempt == 5 { return fmt.Errorf("%s: %s", path, res.Status) } wait, _ := strconv.Atoi(res.Header.Get("Retry-After")) time.Sleep(time.Duration(max(wait, 1)) * time.Second) } } type passport struct { SerialNumber string `json:"serial_number"` PassportID string `json:"passport_id"` PublicURL string `json:"public_url"` CurrentVersion struct { VersionNo int `json:"version_no"` PublishedAt string `json:"published_at"` } `json:"current_version"` } func main() { modelID := os.Args[1] // the id from listBatteryModels out := csv.NewWriter(os.Stdout) defer out.Flush() _ = out.Write([]string{"serial_number", "passport_id", "version_no", "published_at", "public_url"}) cursor := "" for { q := url.Values{"battery_model_id": {modelID}, "passport_status": {"published"}, "limit": {"200"}} if cursor != "" { q.Set("cursor", cursor) } var page struct { Items []struct { ID string `json:"id"` } `json:"items"` NextCursor *string `json:"next_cursor"` } if err := get("/batteries?"+q.Encode(), &page); err != nil { log.Fatal(err) } for _, unit := range page.Items { var p passport if err := get("/batteries/"+unit.ID+"/passport", &p); err != nil { log.Fatal(err) } v := p.CurrentVersion _ = out.Write([]string{p.SerialNumber, p.PassportID, strconv.Itoa(v.VersionNo), v.PublishedAt, p.PublicURL}) } if page.NextCursor == nil { break } cursor = *page.NextCursor } } ``` ```text title="Output" serial_number,passport_id,version_no,published_at,public_url SV2027-000184,urn:dpp:0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10,4,2026-10-24T06:00:03Z,https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10 SV2027-000185,urn:dpp:0192f0d7-3e2a-7b1c-8d4e-5f6a7b8c9d0e,2,2026-09-30T14:22:10Z,https://batterypass.example.com/pass/0192f0d7-3e2a-7b1c-8d4e-5f6a7b8c9d0e ``` The shell version quotes every CSV field (`@csv`); the others quote only where needed. Each unit costs one request, and all keys of your organisation share 600 requests per minute. The passport and its versions are explained in [Passports and versions](https://passport.kabin.sh/docs/en/concepts/passports). --- # Connecting your ERP securely > How an ERP, its middleware and BatteryPass work together, who does what, and what never leaves your systems. Your ERP knows products, serial numbers, production orders, deliveries and returns. BatteryPass holds the battery passports built from that data. This section shows how to connect the two so that data flows without anyone copying spreadsheets by hand, and so that declared passport data still enters BatteryPass only through a person in your organisation who stands behind it. These pages describe **integration patterns**, not products: BatteryPass ships no ERP connector, holds no vendor certification and has no partnership with an ERP vendor. You build the connection with your middleware or iPaaS, on the published API. ## The architecture ```mermaid flowchart LR ERP["ERP
(production, serials,
deliveries, returns)"] -->|"outbox or event"| MW["Middleware / iPaaS"] MW -->|"API key, scope import:
stage spreadsheet"| API["BatteryPass API"] MW -->|"API key, scope condition:
measurements, events"| API API -->|"API key, scope read:
change feed, passports, QR"| MW MW -->|"Digital Link, status"| ERP API --> PORTAL["Portal: members review,
commit, approve"] PORTAL -->|"members publish"| PUB["Published passport
(signed, public tier online)"] ``` 1. The **ERP** records what happens (a production confirmation, a goods receipt, a returned battery) and keeps the change in an outbox table or raises an event. 2. The **middleware** (your own service or an iPaaS) collects the change, turns it into what BatteryPass expects and calls the [API](https://passport.kabin.sh/docs/en/api-reference) with a key of your organisation. 3. **BatteryPass** checks every request against the key's scope, validates the data with the same rules as the portal and records every write in the audit log. 4. **Members** of your organisation review in the portal: they commit staged imports, approve values and publish passports. Keys cannot do any of this. 5. The middleware reads back what it needs (passport status, GS1 Digital Link, QR code) through the [change feed](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes) and writes it to the ERP record. ## Who does what | Actor | Can | Cannot | | ------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | Key with scope `import` | Upload a CSV or XLSX file, map it (also with an import preset), validate it and read its result | Commit the import, approve values, publish | | Key with scope `condition` | Report measured condition data and lifecycle events | Change declared data such as capacity or chemistry | | Key with scope `read` | Read models, units, values, passports, exports and the change feed | Write anything | | Editor (member) | Commit a validated import; the units are created as drafts | Approve values or publish | | Reviewer, admin, owner (member) | Approve imported values, publish passports | Approve values they recorded themselves while the four-eyes rule is on; publish while required data is missing | | Owner (member) | Create and revoke API keys in **Settings → API keys** | | The four-eyes rule is a setting of your organisation. Condition data reported with a key is stored as approved measurements without a reviewer; that is how measured data is meant to work (see [Condition data and lifecycle](https://passport.kabin.sh/docs/en/concepts/condition-and-lifecycle)). ## What never flows BatteryPass needs data about batteries, not about people. Keep these in your ERP: - customer names, addresses, contact data and order numbers; - names of employees, operators or service technicians; - prices, margins and supplier conditions; - vehicle, owner or location data of a battery in use. A serial number is enough to link a passport, an event or a measurement to the record in your ERP. The [security page](https://passport.kabin.sh/docs/en/integrations/security) explains data minimisation and the rest of the security model. ## The five workflows [**Units from production**](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production): Serial numbers become a staged import; members commit, approve and publish. [**Passport links back into the ERP**](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp): Follow the change feed and store the Digital Link and status on the serial record. [**Condition data from BMS and service**](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms): Report measurements with their time; older ones are skipped. [**Returns and repairs**](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs): Completed service orders become lifecycle events, without customer data. [**Product change**](https://passport.kabin.sh/docs/en/integrations/workflows/product-change): A product master change notifies a person, who creates a new model version. [**Security model**](https://passport.kabin.sh/docs/en/integrations/security): Keys, scopes, retries, outbox, data minimisation and the OWASP API Top 10. ## Middleware and standards - [iPaaS and middleware](https://passport.kabin.sh/docs/en/integrations/ipaas): n8n, Make, Power Automate, MuleSoft, Boomi, Workato, Zapier and SAP Integration Suite with a BatteryPass key, and how each polls the change feed. - [Standards](https://passport.kabin.sh/docs/en/integrations/standards): how BatteryPass data relates to DIN DKE SPEC 99100, the IDTA battery passport submodels and Catena-X. ## ERP systems The ERP registry lists, per system, the interfaces an integration can use, how they authenticate, whether they announce changes, and how and when we checked each fact. It is the same data the portal shows under **Settings → Integrations**. | System | Vendor | Deployments | Checked | | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ----------------- | ------------------------------------------------ | | [SAP S/4HANA Cloud Public Edition](https://passport.kabin.sh/docs/en/integrations/erp/sap-s4hana-cloud.md) | SAP | Cloud | checked in the vendor documentation | | [SAP S/4HANA (on-premise and Private Edition)](https://passport.kabin.sh/docs/en/integrations/erp/sap-s4hana-on-premise.md) | SAP | On-premise | checked in the vendor documentation | | [SAP Business One](https://passport.kabin.sh/docs/en/integrations/erp/sap-business-one.md) | SAP | On-premise | checked in the vendor documentation | | [Microsoft Dynamics 365 Business Central](https://passport.kabin.sh/docs/en/integrations/erp/dynamics-365-business-central.md) | Microsoft | Cloud, On-premise | checked in the vendor documentation | | [Microsoft Dynamics NAV 2018](https://passport.kabin.sh/docs/en/integrations/erp/dynamics-nav-2018.md) | Microsoft | On-premise | checked in the vendor documentation | | [Microsoft Dynamics 365 Finance and Supply Chain Management](https://passport.kabin.sh/docs/en/integrations/erp/dynamics-365-finance-operations.md) | Microsoft | Cloud | checked in the vendor documentation | | [Oracle NetSuite](https://passport.kabin.sh/docs/en/integrations/erp/oracle-netsuite.md) | Oracle | Cloud | checked in the vendor documentation | | [Oracle Fusion Cloud Supply Chain Management](https://passport.kabin.sh/docs/en/integrations/erp/oracle-fusion-scm.md) | Oracle | Cloud | checked in the vendor documentation | | [Odoo](https://passport.kabin.sh/docs/en/integrations/erp/odoo.md) | Odoo S.A. | Cloud, On-premise | checked in the vendor documentation | | [Infor CloudSuite (M3, LN)](https://passport.kabin.sh/docs/en/integrations/erp/infor-cloudsuite.md) | Infor | Cloud, On-premise | checked in the vendor documentation; not checked | | [IFS Cloud](https://passport.kabin.sh/docs/en/integrations/erp/ifs-cloud.md) | IFS | Cloud, On-premise | community or marketing source only | | [Epicor Kinetic](https://passport.kabin.sh/docs/en/integrations/erp/epicor-kinetic.md) | Epicor | Cloud, On-premise | community or marketing source only | | [Sage X3](https://passport.kabin.sh/docs/en/integrations/erp/sage-x3.md) | Sage | Cloud, On-premise | not checked | | [abas ERP](https://passport.kabin.sh/docs/en/integrations/erp/abas-erp.md) | abas Software (Forterro) | On-premise, Cloud | community or marketing source only | | [proALPHA ERP](https://passport.kabin.sh/docs/en/integrations/erp/proalpha.md) | proALPHA | On-premise | not checked | | [weclapp](https://passport.kabin.sh/docs/en/integrations/erp/weclapp.md) | weclapp | Cloud | checked in the vendor documentation | | [Xentral](https://passport.kabin.sh/docs/en/integrations/erp/xentral.md) | Xentral ERP Software | Cloud | checked in the vendor documentation | > [!NOTE] > "Checked in the vendor documentation" means we read the vendor's public documentation on the date given. It is not > a test against a live system and not an endorsement by the vendor. Entries marked "community or marketing source > only" or "not checked" need confirmation in your project. --- # The security model of an ERP integration > Keys and scopes, secrets and rotation, transport, safe retries, the outbox, thin events, stale writes, data minimisation, the audit trail and the OWASP API Top 10. An integration between your ERP and BatteryPass moves data that ends up in signed, public battery passports. This page collects the rules that keep that path safe. Most of them are on your side of the connection; where BatteryPass enforces something itself, it says so. Key handling in day-to-day operation is described in [Handle keys securely](https://passport.kabin.sh/docs/en/guides/secure-key-handling). ## Keys and scopes **One key per integration and per environment.** A test middleware and the production middleware get different keys, and so do two integrations that run in the same middleware. You can then revoke one without stopping the others, and the portal's **Last used** column tells you which one is active. **The smallest scope that works.** A key has one or more of three scopes: | Scope | Lets the key | Workflows | | ----------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read` | read models, units, values, passports, exports and the change feed | [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp), [Product change](https://passport.kabin.sh/docs/en/integrations/workflows/product-change), lookups in the other workflows | | `condition` | report measured condition data and lifecycle events | [Condition data](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms), [Returns and repairs](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs) | | `import` | upload, map and validate spreadsheet imports of units, and read them | [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) | No scope lets a key commit an import, approve a value, publish, withdraw, manage members or keys, or change declared data. Those stay with members of your organisation in the portal. A key acts with the rights of a viewer plus its scopes, and it is never limited to some units: a `condition` key can report for every unit of your organisation. ## Secrets - Keep each key in a secrets manager or vault (HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, the iPaaS's credential store) and hand it to the process at start. Never put it into code, configuration under version control, container images, URLs, logs or tickets. The [OWASP Secrets Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html) describes the practice in detail. - Send the key only in the header `Authorization: Bearer bp_live_…`. A request that also carries a portal session cookie is refused, so the actor is never ambiguous. - Mask the `Authorization` header in every log of your middleware and HTTP client. **Rotation.** Keys do not expire. Rotate them on your own schedule and whenever someone who knew a key leaves: create a new key with the same scopes, switch the middleware to it, check that the new key shows a recent **Last used** time, then revoke the old one. Both work at the same time during the switch, so nothing fails. Revocation is immediate and final. ## Transport - Call BatteryPass only over HTTPS (TLS) and verify the server certificate. Do not turn verification off in test environments either: a middleware configuration tends to travel from test to production. - **Egress allow-listing** as a second line: let the middleware reach only the BatteryPass address and the ERP. It does not replace keys and TLS, but it limits what a compromised middleware can talk to. - The same goes the other way: expose the ERP's APIs to the middleware only, never directly to the internet. ## Retries that do not duplicate Networks fail. A request that timed out may or may not have been carried out, so retries must be safe. **Back off.** Retry network errors and `5xx` answers with exponential backoff and jitter (for example 1, 2, 4, 8 seconds plus a random part, at most five attempts). Answer `429 rate_limited` by waiting the seconds of the `Retry-After` header; all keys of your organisation share 600 requests per minute. Do not retry other `4xx` answers: the request was refused as a whole, so fix it. Details are in [Handle errors and retries](https://passport.kabin.sh/docs/en/guides/handle-errors) and [Rate limits](https://passport.kabin.sh/docs/en/rate-limits). **Make each retry safe.** BatteryPass accepts an `Idempotency-Key` header only on bulk and publishing operations of the portal, which keys cannot call. For the operations a key uses, the safety comes from the operations themselves and from your middleware: | Operation | Safe to repeat | Why, or what you do | | ------------------------------------------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Every `GET` (reads, the change feed, `getImport`) | Yes | They change nothing. | | [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition) | Yes, with the same `measured_at` | A measurement equal to or older than the stored one is skipped. | | [`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import) | Yes, with care | A repeated upload creates a second import. Its rows become duplicates once the first one is committed, so no unit is created twice; members simply do not commit the extra import. | | [`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping) | Yes | With the same `If-Match` the repetition is refused as stale (`409`); read the import and you see the mapping already applied. | | [`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import) | Yes | A second start while the first runs is refused (`409`); poll the import instead. | | [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event) | No | Every call records a new event. Remember which source record (for example the service order) you already reported, and look at [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) after a timeout before you send again. | The IETF is working on a general `Idempotency-Key` header ([draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/), draft 08, not yet an RFC). It is useful background for your own APIs; BatteryPass does not accept it on the operations above. ## The outbox on the ERP side The ERP must not lose a change while the middleware or BatteryPass is unreachable, and it must not call BatteryPass inside its own business transaction. The **transactional outbox** solves both: in the same database transaction that posts the goods receipt, the ERP (or an extension of it) writes a row to an outbox table. The middleware reads the outbox, calls BatteryPass, and marks the row done only after a successful answer. This gives **at-least-once delivery**: a row may be sent twice (after a crash between call and marking), never zero times. Combine it with the retry rules above, and keep a small table of what you already sent (source record, BatteryPass id, time) so you can de-duplicate on your side. ## Thin events, then fetch Do not move data inside notifications. Both directions work the same way: - From the ERP: many ERPs send thin events (a product number and the kind of change). The middleware reads the current record from the ERP's API afterwards, checks the vendor's signature, handshake or secret URL, and drops events it cannot verify. - From BatteryPass: the [change feed](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes) lists only `{id, occurred_at, entity_type, entity_id, action}`, never values, actors or personal data. The middleware reads the entity with the same key afterwards, so the feed reveals nothing on its own and is always current. The feed returns a change only once every older transaction in BatteryPass has finished, so a stored cursor never skips a change that committed late. Store the cursor only after your side has processed the page. BatteryPass sends no outbound webhooks yet; they are planned and need a decision of their own (signing, subscription management, protection of target addresses). Until then, poll. ## Stale writes Mutable resources carry a `row_version`. [`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping) requires `If-Match: ""`; when someone else changed the import in between, the answer is `409 conflict` and nothing is overwritten. Read the import again and decide. Condition data has its own rule: an older measurement never replaces a newer one. ## Data minimisation The GDPR asks for data that is "adequate, relevant and limited to what is necessary" (Art. 5(1)(c)). For a battery passport that means data about the battery: serial number, model, dates, measurements, events. Customer names and addresses, order numbers, employee names, prices, vehicle and location data stay in the ERP. Each [workflow](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) lists what it sends and what it leaves out, and the [overview](https://passport.kabin.sh/docs/en/integrations) lists what never flows. Free text is where personal data slips in: an event description such as "repaired for Mr. X at Y" ends up in your battery's history. Fill descriptions from fixed codes or templates, not from free text of a service order. ## The audit trail - Every write with a key is recorded in your organisation's audit log with the actor type `api_key` and the key's name: staging and validating an import, a condition update, a lifecycle event. - Imports staged with a key name the key instead of a member (`created_by_api_key`), in the API and in the portal. - Values written with a key do not carry the key themselves. A condition update creates a `condition_update` lifecycle event, and that event names the key (`api_key`). - Condition data is stored as approved measurements without a reviewer. Declared data takes the reviewed path: imported values are approved by a reviewer, and whether a second person must approve is your organisation's four-eyes setting. - Reads are not recorded. Assume that a leaked key's data was read. ## OWASP API Security Top 10 How the [OWASP API Security Top 10 (2023)](https://api-security.owasp.org/editions/2023/en/0x11-t10) risks that matter most for an ERP integration are handled, and what remains your part: | Risk | In BatteryPass | Your part | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | API1:2023 Broken object level authorization | Every query is filtered by the key's organisation; another organisation's id answers `404`. | Use one key per organisation you connect; never mix tenants in one middleware flow. | | API4:2023 Unrestricted resource consumption | 600 requests per minute for all keys of an organisation, 30 attempts per 15 minutes for unknown keys, request bodies of 1 MiB, uploads of 10 MB and 10 000 rows, lists of at most 200 items. | Back off on `429`, page with `limit` and `cursor`, and send files in batches. | | API5:2023 Broken function level authorization | Each operation names its scope (`x-api-key-scope`); a key without it gets `403`, and portal operations do not accept keys at all (`401`). | Give each key only the scopes its workflow lists. | | API7:2023 Server side request forgery | BatteryPass does not call addresses that an integration supplies; there are no webhooks yet. | When the middleware fetches URLs from ERP events, allow-list the targets. | | API10:2023 Unsafe consumption of APIs | BatteryPass validates every value with the registry's rules, whatever the source. | Treat ERP events and BatteryPass answers as untrusted input: validate, check signatures where the vendor offers them, and never execute received content. | --- # iPaaS and middleware > Call the BatteryPass API from n8n, Make, Power Automate, MuleSoft, Boomi, Workato, Zapier and SAP Integration Suite with a key from the platform's secret store, and poll the change feed. Most ERP integrations run on an integration platform rather than on custom code. Every platform below can call BatteryPass, because the API needs only two things: an HTTPS request with the header `Authorization: Bearer `, and JSON (or `multipart/form-data` for imports). This page shows where the key goes on each platform and how each one follows the [change feed](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes). The platforms are described from their public documentation. BatteryPass offers no connector of its own on any of them and has no partnership with their vendors. ## What all platforms do the same way - **The key lives in the platform's credential store**, never in a flow definition, a script or a variable that appears in run logs. Use one key per flow and environment (see [Security](https://passport.kabin.sh/docs/en/integrations/security)). - **The contract.** BatteryPass publishes its integration contract as OpenAPI 3.0.3 at `/api/v1/openapi.json` and `/api/v1/openapi.yaml` on your BatteryPass address, and as a download in the [API reference](https://passport.kabin.sh/docs/en/api-reference). Platforms that import OpenAPI 3 build their operations from it. - **Polling instead of webhooks.** BatteryPass sends no outbound webhooks yet; they are planned and need a decision of their own. Schedule a flow (every few minutes) that calls `GET /api/v1/changes` with the stored cursor, processes the items oldest first, and stores `next_cursor` when it is not `null`. The pattern is the same everywhere: ```text title="Polling loop" cursor = load from the platform's data store (empty on the first run) repeat: GET /api/v1/changes?types=passport.published,passport.republished&limit=100&cursor={cursor} for each item: read the entity, update the ERP if next_cursor is null: stop (an empty page keeps the old cursor) cursor = next_cursor, save it (only after the ERP accepted the page) ``` - **Errors.** Retry `429` after `Retry-After` and `5xx` with backoff; do not retry other `4xx` (see [Handle errors and retries](https://passport.kabin.sh/docs/en/guides/handle-errors)). ## Platforms **n8n** Use the **HTTP Request** node with a **Header Auth** credential: name `Authorization`, value `Bearer bp_live_…` ([n8n: HTTP Request credentials](https://docs.n8n.io/integrations/builtin/credentials/httprequest/)). The credential is stored encrypted and not shown in executions. Polling: a **Schedule Trigger** starts the flow; keep the cursor in the workflow's static data or a small database table, call `GET /changes` in an HTTP Request node, split the items, and loop while `next_cursor` is set. For imports, send `multipart/form-data` with a binary property for `file` and a form field `preset`. **Make** Use the **HTTP** app and store the key in a keychain of type **API key** that adds the header `Authorization` with `Bearer bp_live_…` ([Make: HTTP](https://apps.make.com/http)). Keychains keep the secret out of the scenario blueprint. Polling: a scheduled scenario, the cursor in a **Data store** record; the **Repeater** or an iterator walks the pages until `next_cursor` is empty. **Power Automate and Azure Logic Apps** Custom connectors accept **only OpenAPI 2.0 (Swagger)** definitions, and files must stay under 1 MB; API key authentication is supported ([Microsoft: custom connector FAQ](https://learn.microsoft.com/en-us/connectors/custom-connectors/faq)). BatteryPass publishes OpenAPI 3.0.3 today, so you cannot import our contract as a custom connector directly. A Swagger 2.0 export of the integration contract is planned as a follow-up. Until then, use the built-in **HTTP** action: put the key into Azure Key Vault, read it with the Key Vault connector (mark the output as secure so it does not appear in run history) and set the header `Authorization: Bearer @{…}`. Polling: a **Recurrence** trigger, the cursor in a table or a variable stored in Dataverse or Azure Table Storage, and a **Do until** loop over the pages. **MuleSoft Anypoint** **REST Connect** in Anypoint Exchange generates a connector from an OpenAPI 2.0 or 3.0 specification ([MuleSoft: REST Connect](https://docs.mulesoft.com/exchange/to-deploy-using-rest-connect)), so the published `openapi.json` can be the source. Alternatively call the API with the HTTP Request connector. Keep the key in secure configuration properties (encrypted) or a secrets manager, and set it as the `Authorization` header of the connector configuration. Polling: a **Scheduler** source with the cursor in an Object Store. **Boomi** The **OpenAPI** connector imports OpenAPI 3.0 and later ([Boomi: OpenAPI connector](https://help.boomi.com/docs/Atomsphere/Integration/Connectors/int-OpenAPI_connector_6766251b-b601-41f5-8c13-86d79faf52b9)). Import the published contract, or use the HTTP Client connector. Store the key as an encrypted connection field or in an extension marked as password, and send it as the `Authorization` header. Polling: a scheduled process with the cursor in a persisted process property. **Workato** Use the **HTTP** connector with a connection that sets the header `Authorization: Bearer …`; Workato encrypts connection fields. Triggers that receive webhooks need a custom connector built with the connector SDK ([Workato: dynamic webhook triggers](https://docs.workato.com/en/developing-connectors/sdk/guides/building-triggers/dynamic-webhook.html)); since BatteryPass has no webhooks yet, use a **scheduled recipe** that polls `GET /changes` and keeps the cursor in a lookup table. **Zapier** Zapier's instant triggers use **REST hooks**, which need subscribe and unsubscribe endpoints on the API side ([Zapier: REST hook trigger](https://docs.zapier.com/integrations/build/hook-trigger)). BatteryPass does not offer them yet. Use a polling trigger instead: **Webhooks by Zapier** (Retrieve Poll) or a private app's polling trigger against `GET /changes`, with the key in an API key authentication field. Zapier de-duplicates polled items by their `id`, which every change carries. **SAP Integration Suite (Cloud Integration)** Use the **HTTP receiver adapter**. Store the key as a secure parameter in the tenant's security material and set the header `Authorization` in a content modifier or script from it, never as a plain externalized parameter. Watch out: Cloud Integration can drop custom headers from the message unless they are allowed. Add `Authorization` (or the header you set) to **Allowed Header(s)** in the integration flow's runtime configuration; SAP Note 3075702 describes the symptom of headers not being sent. Polling: a **Timer** start event, the cursor in a data store or global variable, and a looping process call over the pages. ## Uploading imports from a platform [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) needs `multipart/form-data`: the file in the part `file` (with a file name and a content type `text/csv` or the XLSX type), and the preset id in the part `preset`. Every platform above can send multipart requests from its HTTP connector; build the CSV in the flow and attach it as binary content. Then poll `GET /imports/{id}` until `status` is `validated` or `failed`. --- # How BatteryPass relates to battery passport standards > DIN DKE SPEC 99100, the IDTA Digital Battery Passport submodels, Catena-X CX-0160 and the BatteryPass data model, and what BatteryPass maps today and what it does not. The legal basis of the battery passport is Regulation (EU) 2023/1542, Article 77 and Annex XIII. Several standards and data models describe how the passport's data can be structured and exchanged. This page says plainly how the data in BatteryPass relates to each of them, what is mapped today and what is not. None of it promises that your passports meet the requirements: that depends on your data and remains your responsibility. ## Overview | Standard or model | What it is | Status (checked 2026-10-24) | BatteryPass today | | ---------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | DIN DKE SPEC 99100 | Requirements for the data attributes of the battery passport | Published 2025-02 | The field registry follows its structure; each registry section names its chapter | | IDTA 02035, parts 1 to 7 | Digital Battery Passport submodels for the Asset Administration Shell | Version 1.0, February 2026 | No AAS export yet; fields can be mapped through the shared DIN DKE SPEC structure | | Catena-X CX-0160 | Battery passport standard of the Catena-X data space | Version 1.0.0 only in the unreleased CX-Neptune preview | Not a Catena-X connector; identifiers map as described below | | BatteryPass-Ready Data Model | JSON schemas of the BatteryPass-Ready consortium | Version 2.0 (schemas 2.0.1); the older consortium model 1.2.0 is no longer maintained | Export of every published version as BatteryPass v2.0 JSON, with an advisory schema check | | GBA Battery Passport | Global Battery Alliance passport | Voluntary, outside the EU regulation | Not produced | ## DIN DKE SPEC 99100 [DIN DKE SPEC 99100](https://www.dinmedia.de/de/technische-regel/din-dke-spec-99100/385692321) (2025-02) lists the data attributes of the battery passport and groups them in chapters. The BatteryPass [field registry](https://passport.kabin.sh/docs/en/general/glossary) is built along the same chapters: every section returned by [`getRegistrySchema`](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-schema) carries `din_spec` with the chapter it covers, and fields carry their legal references. | Registry section | DIN DKE SPEC 99100 | | ------------------------------------------------------------------------------- | ------------------ | | `identification` (identification and product data) | 6.1 | | `labeling` (labelling and conformity) | 6.2 | | `carbon_footprint` | 6.3 | | `due_diligence` | 6.4 | | `materials`, `hazardous` (materials and composition, hazardous substances) | 6.5 | | `circularity` | 6.6 | | `performance`, `condition` (performance and durability, dynamic condition data) | 6.7 | The registry decides which fields a battery category needs and from when; where the registry and the specification differ in detail, the registry follows the regulation. ## IDTA Digital Battery Passport (Asset Administration Shell) The Industrial Digital Twin Association published the submodel templates IDTA 02035-1 to 02035-7 "Digital Battery Passport" in version 1.0 in February 2026, developed together with Catena-X and aligned with DIN DKE SPEC 99100. They describe the passport as submodels of an Asset Administration Shell (AAS); part 1 is the digital nameplate, part 2 the handover documentation, the further parts follow the chapters of the specification. BatteryPass does not export AAS submodels today. Because both follow DIN DKE SPEC 99100, a middleware can map the values it reads with [`listBatteryValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-values) and [`listModelVersionValues`](https://passport.kabin.sh/docs/en/api-reference/values/list-model-version-values) to the submodel elements chapter by chapter. An AAS export from BatteryPass is a possible follow-up, not a current feature. ## Catena-X CX-0160 The Catena-X standard CX-0160 "Battery Passport" (version 1.0.0) exists only in the preview of the CX-Neptune release and is not released ([CX-0160 preview](https://catenax-ev.github.io/docs/next/standards/CX-0160-BatteryPassport)). In Catena-X a passport is a digital twin registered in a Digital Twin Registry (CX-0002), exchanged through an Eclipse Dataspace Connector (EDC) between partners identified by business partner numbers (BPNL for the legal entity, BPNS for a site). **BatteryPass is not a Catena-X connector**: it registers no digital twins, runs no EDC and holds no business partner numbers. A manufacturer that takes part in Catena-X can feed its own connector from the BatteryPass API. The identifiers line up like this: | Catena-X | BatteryPass | | ----------------------------------------- | --------------------------------------------------------------- | | `manufacturerPartId` (the catalogue part) | the battery model: its model identifier (`sku`) or GTIN | | `partInstanceId` (the serialised part) | the unit's `serial_number` | | the passport's own identifier | the unit's `passport_id` (`urn:dpp:…`) and its GS1 Digital Link | ## BatteryPass-Ready data model The BatteryPass consortium's data model 1.2.0 is no longer maintained; its successor is the BatteryPass-Ready Data Model, whose JSON schemas BatteryPass ships in version 2.0.1. For every published passport version, [`exportPassportJson`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-json) returns the version as BatteryPass v2.0 JSON, built when the version was published. The schema check is advisory: the version reports `batterypass_v2_valid` and the number of issues, and a failing check does not block publication. Details are in [Export passports](https://passport.kabin.sh/docs/en/guides/export-passports). ## GBA Battery Passport The Global Battery Alliance runs a voluntary battery passport programme with its own rules and indicators. It is not the EU battery passport, and BatteryPass does not produce GBA passports. --- # Units from production > Stage the serial numbers of finished batteries as an import with a key of scope import; members review, commit, approve and publish in the portal. When production confirms finished batteries, their serial numbers should become units in BatteryPass without anyone retyping them. In this workflow the middleware stages a spreadsheet import with a key of scope `import`. **Committing the import and approving its values stay with members of your organisation in the portal**: a key can prepare the data, but declared data enters BatteryPass only through a person's decision (and the four-eyes rule, where your organisation has it switched on). ```mermaid sequenceDiagram participant ERP participant MW as Middleware participant BP as BatteryPass API participant M as Member in the portal ERP->>ERP: post goods receipt, write outbox row MW->>ERP: read outbox rows (serial, GTIN, date) MW->>BP: createImport with file and preset (scope import) BP-->>MW: 201 import, status uploaded MW->>BP: validateImport (scope import) BP-->>MW: 202 validation job loop every few seconds MW->>BP: getImport (scope import) end BP-->>MW: status validated, summary MW->>ERP: mark rows done, report the summary M->>BP: review the report, commit the import M->>BP: approve the imported values M->>BP: publish the passports ``` **Trigger:** The production confirmation or the goods receipt from production posts the serial numbers of finished batteries. **Key scopes:** import | # | Who | What | Operation | | - | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | 1 | ERP | Posts the confirmation or goods receipt with the serial numbers and records the change in an outbox table. | | | 2 | Middleware | Collects the serial numbers of a batch with model identifier or GTIN and manufacturing date and writes a CSV or XLSX file with the preset's column names. | | | 3 | Middleware | Uploads the file with the preset and keeps the import id. | [`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import.md) (Scope: import) | | 4 | Middleware | Starts the validation of every row. | [`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import.md) (Scope: import) | | 5 | Middleware | Polls the import until it is validated or failed and reports the summary (valid, warnings, errors, duplicates) back to production. | [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import.md) (Scope: import) | | 6 | Member in the portal | Reviews the validation report in the portal and commits the import; the units are created as drafts. | | | 7 | Member in the portal | Approves the imported values in one step (ADR-015); with the organisation's four-eyes rule switched on, values the approver recorded stay open. | | | 8 | Member in the portal | Publishes the passports once no required value is missing. | | **Security** - A key of its own with the scope import only; it can stage imports but neither commit nor approve them. - A repeated upload creates a second import; its rows become duplicates once the first is committed, so no unit is created twice. - The outbox in the ERP makes sure no posting is lost while the middleware or BatteryPass is unreachable. **Data minimisation** - The file holds the serial number, model identifier or GTIN, manufacturing date and at most facility and model version. - Customers, orders, prices and employee names stay in the ERP. ## Before you start - An owner creates a key with the scope `import` only, for example `MES line 2 imports`, under **Settings → API keys**. It cannot read your passports, report condition data or commit anything. - The battery models exist in BatteryPass with their GTIN or model identifier, and a model version covers the manufacturing dates you send. The import finds each row's model by the GTIN, the model identifier or the product name. - Pick an [import preset](https://passport.kabin.sh/docs/en/integrations#erp-systems) that matches what your middleware writes. The preset `generic-csv` takes the columns `GTIN`, `Serial number`, `Manufacturing date`, `Facility` (name or GLN), `Model version` and `Battery status`, in German or English. ERP-specific presets (SAP, Business Central, Odoo, NetSuite) are listed on each [ERP page](https://passport.kabin.sh/docs/en/integrations/erp/sap-s4hana-cloud). ## 1. Write the file The middleware collects the serial numbers of one batch from its outbox and writes a CSV (UTF-8, separated by `;`, `,`, tab or `|`) or XLSX file with the preset's headers. At most 10 MB, 10 000 rows and 200 columns per file. Columns the preset does not know are left unmapped, so extra columns do no harm. ```text title="batch-2026-10-24.csv" GTIN;Serial number;Manufacturing date;Facility 04012345000023;SV2027-000901;2026-10-23;Werk Landshut 04012345000023;SV2027-000902;2026-10-23;Werk Landshut ``` ## 2. Upload with the preset [`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import) takes the file as `multipart/form-data` in the field `file`, and the preset in `preset`. The answer is the import with the mapping taken from the preset: ```bash title="cURL" curl -sS -X POST "$BATTERYPASS_URL/api/v1/imports" \ -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY" \ -F "file=@batch-2026-10-24.csv;type=text/csv" \ -F "preset=generic-csv" ``` ```ts title="TypeScript" import { readFile } from 'node:fs/promises'; const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const auth = { Authorization: `Bearer ${process.env.BATTERYPASS_IMPORT_KEY}` }; const csv = await readFile('batch-2026-10-24.csv', 'utf8'); const form = new FormData(); form.append('file', new Blob([csv], { type: 'text/csv' }), 'batch-2026-10-24.csv'); form.append('preset', 'generic-csv'); const res = await fetch(`${BASE}/imports`, { method: 'POST', headers: auth, body: form }); const imp = await res.json(); if (res.status !== 201) throw new Error(`createImport: ${res.status} ${imp.code} (request_id ${imp.request_id})`); console.log(imp.id, imp.status, imp.preset); ``` ```python title="Python" import os import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_IMPORT_KEY"] with open("batch-2026-10-24.csv", "rb") as f: res = session.post(f"{BASE}/imports", files={"file": ("batch-2026-10-24.csv", f, "text/csv")}, data={"preset": "generic-csv"}, timeout=60) imp = res.json() if res.status_code != 201: raise SystemExit(f"createImport: {res.status_code} {imp['code']} (request_id {imp.get('request_id')})") print(imp["id"], imp["status"], imp["preset"]) ``` ```json title="201 (shortened)" { "id": "0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f", "status": "uploaded", "filename": "batch-2026-10-24.csv", "format": "csv", "row_count": 2, "headers": ["GTIN", "Serial number", "Manufacturing date", "Facility"], "mapping": [ { "column": 0, "header": "GTIN", "target": "identification.gtin" }, { "column": 1, "header": "Serial number", "target": "identification.serial_number" }, { "column": 2, "header": "Manufacturing date", "target": "identification.manufacturing_date" }, { "column": 3, "header": "Facility", "target": "identification.manufacturing_place" } ], "options": {}, "preset": "generic-csv", "created_by_api_key": { "id": "0192f0aa-1b2c-7d3e-8f40-5a6b7c8d9e01", "name": "MES line 2 imports" }, "created_at": "2026-10-24T06:05:11Z", "updated_at": "2026-10-24T06:05:11Z", "row_version": 1 } ``` The import names the key in `created_by_api_key`; members see it in the portal's import list as staged by that key. ## 3. Optional: set options When the file has no model column, or no facility column, set them for every row with [`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping). It replaces the mapping, so send the preset again (or a full `mapping`, never both), and `If-Match` with the import's `row_version`: ```bash title="cURL" curl -sS -X PUT "$BATTERYPASS_URL/api/v1/imports/0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f/mapping" \ -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY" \ -H 'If-Match: "1"' \ -H "Content-Type: application/json" \ -d '{"preset": "generic-csv", "options": {"facility_id": "0192f09e-4a5b-7c6d-9e8f-0a1b2c3d4e5f"}}' ``` A changed import answers `409 conflict`: read it again with [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import) and retry with the new `row_version`. ## 4. Validate and wait [`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import) starts a job that checks every row with the portal's validators and finds serial numbers that repeat in the file or already exist. Poll [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import) until `status` is `validated` (or `failed`, when the file could not be read): ```bash title="cURL" curl -sS -X POST "$BATTERYPASS_URL/api/v1/imports/0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f/validate" \ -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY" curl -sS "$BATTERYPASS_URL/api/v1/imports/0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f" \ -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY" | jq '{status, summary}' ``` ```ts title="TypeScript" const res = await fetch(`${BASE}/imports/${imp.id}/validate`, { method: 'POST', headers: auth }); if (res.status !== 202) throw new Error(`validateImport: ${res.status} ${(await res.json()).code}`); let current = imp; for (let attempt = 0; attempt < 120 && !['validated', 'failed'].includes(current.status); attempt++) { await new Promise((r) => setTimeout(r, 5000)); current = await (await fetch(`${BASE}/imports/${imp.id}`, { headers: auth })).json(); } // Report back to production: valid and warning rows will be imported, errors and duplicates will not. console.log(current.status, current.summary); ``` ```python title="Python" import time res = session.post(f"{BASE}/imports/{imp['id']}/validate", timeout=30) if res.status_code != 202: raise SystemExit(f"validateImport: {res.status_code} {res.json()['code']}") current = imp for _ in range(120): if current["status"] in ("validated", "failed"): break time.sleep(5) current = session.get(f"{BASE}/imports/{imp['id']}", timeout=30).json() # Report back to production: valid and warning rows will be imported, errors and duplicates will not. print(current["status"], current.get("summary")) ``` ```json title="200 (shortened)" { "status": "validated", "summary": { "rows": 2, "valid": 2, "warnings": 0, "errors": 0, "duplicates": 0, "created": 0, "approved": 0, "not_approved": 0 } } ``` Mark the outbox rows done once the import is validated, and send the summary to whoever runs production. Rows with errors or duplicates are not imported; the members see each one with row, column and reason in the portal. ## 5. Members commit, approve and publish From here on the work happens in the portal, never with a key: 1. An editor opens the import, reads the validation report and **commits** it. The units are created as drafts, their values carry the source `import`. 2. A reviewer **approves** the imported values in one step. Values with a failing check, without required evidence or with an import warning stay open for individual review; with the four-eyes rule switched on, values the reviewer recorded themselves stay open too. 3. A reviewer **publishes** the passports once no required value is missing. The [change feed](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp) reports `import.committed`, `battery.created`, `import.approved` and later `passport.published`, so the ERP learns about each step without asking the people. ## Failure handling | Answer | Meaning | What to do | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `400 validation_error` | Field `file` (empty, not CSV or XLSX), `preset` (unknown preset), `mapping` (no serial number column or no way to find the model) | Fix the file or the preset; do not retry unchanged. | | `401 unauthenticated` | The key is missing, unknown or revoked | Check the secret; alert. | | `403 forbidden` | The key lacks the scope `import` | Use the import key. | | `404 not_found` | The import does not exist in your organisation | Start a new import. | | `409 conflict` | The import changed (`If-Match`) or is being validated or committed | Read the import and decide. | | `413 payload_too_large` | The file is larger than the upload limit | Split the batch. | | `429 rate_limited` | Too many requests | Wait `Retry-After` seconds. | | `5xx`, timeout | Unknown outcome | Retry with backoff. A repeated upload creates a second import whose rows become duplicates once the first is committed; members commit only one. | Status `failed` means the file could not be read during validation: upload a corrected file as a new import. --- # 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. | --- # Condition data from BMS and service > A battery management system or a service tool reports measurements per unit with updateCondition; newer measurements are accepted, older ones skipped, and the passport is re-published by your policy. Dynamic battery data (state of health, capacity fade, full cycles, temperatures) comes from the battery management system (BMS), a monitoring platform or a service tool, sometimes through the ERP's service module. In this workflow the middleware reports it per unit with [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition). The details of the operation are in [Push condition data](https://passport.kabin.sh/docs/en/guides/push-condition-data); this page shows the workflow around it. ```mermaid sequenceDiagram participant BMS as BMS or service tool participant MW as Middleware participant BP as BatteryPass API BMS->>MW: new measurements per serial number MW->>MW: look up the BatteryPass id in its own mapping alt serial not mapped yet MW->>BP: listBatteries with serial_prefix (scope read) BP-->>MW: the unit end MW->>BP: updateCondition with measured_at (scope condition) BP-->>MW: accepted, skipped, publication BP->>BP: condition_update event naming the key, re-publication by policy ``` **Trigger:** The BMS or the service tool has new measurements of a unit. **Key scopes:** read, condition | # | Who | What | Operation | | - | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | 1 | Middleware | Collects the new measurements per unit with the time of measurement. | | | 2 | Middleware | Finds the unit by its serial number (filter serial_prefix) and keeps the BatteryPass id in its own mapping table. | [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries.md) (Scope: read) | | 3 | Middleware | Reports the values with measured_at; older or equally old measurements are skipped, so a retry does no harm. | [`updateCondition`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition.md) (Scope: condition) | | 4 | BatteryPass | Stores the accepted values as measurements (approved without a reviewer, ADR-016), records a condition_update lifecycle event that names the key, and re-publishes immediately or daily as the organisation has set it. | | **Security** - A key of its own per BMS platform or service partner, with the scopes condition and read. - On 429 the middleware waits the seconds of Retry-After, otherwise it retries with exponential backoff and jitter. **Data minimisation** - Only the unit's dynamic fields; no location data, no vehicle or owner data. ## The key One key per BMS platform or service partner, with the scopes `condition` (to report) and `read` (to find units). Two keys, one per scope, work just as well. A `condition` key can report for every unit of your organisation, so hand a key to a service partner only if you would let the partner do that. ## Find the unit once The middleware keeps a mapping table from serial number to the BatteryPass id. For a serial it does not know yet, it asks [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries) with `serial_prefix` and picks the unit whose `serial_number` matches exactly (serial numbers are unique per model; add `battery_model_id` when two models share a numbering scheme): ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/batteries?serial_prefix=SV2027-000184&limit=10" \ -H "Authorization: Bearer $BATTERYPASS_BMS_KEY" | jq '.items[] | select(.serial_number == "SV2027-000184") | .id' ``` ## Report the measurements ```bash title="cURL" curl -sS -X PATCH "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/condition" \ -H "Authorization: Bearer $BATTERYPASS_BMS_KEY" \ -H "Content-Type: application/json" \ -d '{"measured_at": "2026-10-24T06:00:00Z", "note": "BMS daily report", "values": [{"field_key": "condition.capacity_fade", "value": 3.5}, {"field_key": "condition.number_of_full_cycles", "value": 412}]}' ``` ```ts title="TypeScript" const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_BMS_KEY}`, 'Content-Type': 'application/json' }; type Reading = { fieldKey: string; value: number; measuredAt: string }; export async function report(batteryId: string, readings: Reading[]) { const body = { note: 'BMS daily report', values: readings.map((r) => ({ field_key: r.fieldKey, value: r.value, measured_at: r.measuredAt })), }; for (let attempt = 0; ; attempt++) { const res = await fetch(`${BASE}/batteries/${batteryId}/condition`, { method: 'PATCH', headers, body: JSON.stringify(body) }); if (res.ok) return res.json(); // { accepted, skipped, publication, event } const problem = await res.json().catch(() => ({})); const retry = res.status === 429 || res.status >= 500; if (!retry || attempt >= 4) throw new Error(`updateCondition: ${res.status} ${problem.code} (request_id ${problem.request_id})`); const wait = res.status === 429 ? Number(res.headers.get('Retry-After') ?? 1) * 1000 : 2 ** attempt * 1000; await new Promise((r) => setTimeout(r, wait + Math.random() * 500)); // backoff with jitter } } ``` ```python title="Python" import os import random import time import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_BMS_KEY"] def report(battery_id, readings): """readings: [(field_key, value, measured_at)]; returns accepted, skipped, publication and event.""" body = { "note": "BMS daily report", "values": [{"field_key": k, "value": v, "measured_at": at} for k, v, at in readings], } for attempt in range(5): res = session.patch(f"{BASE}/batteries/{battery_id}/condition", json=body, timeout=30) if res.ok: return res.json() if res.status_code != 429 and res.status_code < 500: problem = res.json() raise SystemExit(f"updateCondition: {res.status_code} {problem['code']} (request_id {problem.get('request_id')})") wait = int(res.headers.get("Retry-After", "1")) if res.status_code == 429 else 2**attempt time.sleep(wait + random.random() / 2) # backoff with jitter raise SystemExit("updateCondition: gave up after 5 attempts") ``` Because a measurement equal to or older than the stored one is skipped, sending the same reading again after a timeout changes nothing: this is what makes the retry above safe. Send measurements of one unit in the order they were taken. ## What BatteryPass does with them - Accepted values are stored as **approved measurements without a reviewer** (measured data has no review step) with their `measured_at`. - The update records a `condition_update` lifecycle event; that event names the key (`api_key`) and has the source `api`. The values themselves do not carry the key. The audit log names the key as the actor. - A published passport is re-published as your organisation's setting says: at once (`publication.outcome` is `published` or `unchanged`) or by the daily job (`scheduled`). `blocked` means a required value is missing now; the passport is `update_needed` and a member must act. ## What never flows Only the unit's dynamic fields (registry section `condition`). No location or GPS data, no vehicle or owner data, no operator names. A note is free text that ends up in the unit's history: fill it from a fixed template. ## Failure handling | Answer | Meaning | What to do | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | | `400 validation_error` | A field that is not a dynamic field of the unit (`unknown_field`), a value outside its range, a time in the future or before manufacture, or a failing cross-field check | Fix the reading; the whole update was refused. | | `401 unauthenticated` | Key missing, unknown or revoked | Check the secret; alert. | | `403 forbidden` | The key lacks the scope `condition` | Use the right key. | | `404 not_found` | The unit is not in your organisation | Refresh the mapping table. | | `409 conflict` | The unit's model is archived | Stop reporting for it. | | `429 rate_limited` | Too many requests | Wait `Retry-After` seconds; spread daily reports over time. | | `5xx`, timeout | Unknown outcome | Retry the same body with backoff; repeated measurements are skipped. | --- # Returns and repairs as lifecycle events > Completed service, repair and return orders in the ERP become lifecycle events of the unit, without customer data and without duplicates. When a battery comes back for inspection, repair or a component replacement, the ERP or its service module closes an order. In this workflow the middleware turns the completed order into a lifecycle event of the unit with [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event). Events are records: they appear in the unit's history and change no passport data. How events work is described in [Record lifecycle events](https://passport.kabin.sh/docs/en/guides/record-lifecycle-events). ```mermaid sequenceDiagram participant ERP participant MW as Middleware participant BP as BatteryPass API participant M as Member in the portal ERP->>ERP: complete the order, write outbox row MW->>ERP: read outbox row (serial, kind of work, date) MW->>MW: already reported this order? then stop MW->>BP: listBatteries with serial_prefix (scope read) MW->>BP: createLifecycleEvent (scope condition) BP-->>MW: 201 event MW->>MW: remember order and event id M->>BP: check the history, create a second life if needed ``` **Trigger:** A service, repair or return order is completed in the ERP. **Key scopes:** read, condition | # | Who | What | Operation | | - | -------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | 1 | ERP | Completes the order with the serial number and the kind of work and records the change in the outbox. | | | 2 | Middleware | Finds the unit by its serial number. | [`listBatteries`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries.md) (Scope: read) | | 3 | Middleware | Records the event (for example repair, inspection or component_replaced) with its date and a short description. | [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event.md) (Scope: condition) | | 4 | Member in the portal | Checks the history in the portal; a member creates a second life (repurposing, remanufacturing) as a new unit. | | **Security** - Events are records and change no passport data; the key needs only condition and read. - The middleware remembers the order number it recorded an event for and does not record it a second time. **Data minimisation** - No customer names, addresses, order numbers or fault descriptions that identify people in the description; the serial number links event and order. ## Map the order to an event type | ERP order | `type` | | ------------------------------------ | -------------------- | | Inspection or diagnosis without work | `inspection` | | Repair | `repair` | | A module, cell or part replaced | `component_replaced` | | Battery taken back for disposal | `waste` | | Battery recycled | `recycled` | | Anything else worth recording | `custom` | Condition values measured during service are not part of the event: report them with [`updateCondition`](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms), which records its own `condition_update` event. A repurposed or remanufactured battery gets a new unit and passport, which a member creates in the portal (second life). ## Record the event `occurred_on` is the day the work was done, between the manufacturing date and today. `description` takes at most 2 000 characters; `evidence_ids` can name up to 20 documents of your evidence library, such as a repair protocol. ```bash title="cURL" curl -sS -X POST "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/lifecycle-events" \ -H "Authorization: Bearer $BATTERYPASS_SERVICE_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "component_replaced", "occurred_on": "2026-10-21", "description": "Module 3 replaced (service code R-12)"}' ``` ```ts title="TypeScript" const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_SERVICE_KEY}`, 'Content-Type': 'application/json' }; // reported: your own table of orders already sent (order number → event id). export async function recordOrder(order: { number: string; batteryId: string; type: string; doneOn: string; code: string }, reported: Map) { if (reported.has(order.number)) return reported.get(order.number); // never send an order twice const res = await fetch(`${BASE}/batteries/${order.batteryId}/lifecycle-events`, { method: 'POST', headers, body: JSON.stringify({ type: order.type, occurred_on: order.doneOn, description: `Service code ${order.code}` }), }); const body = await res.json(); if (res.status !== 201) throw new Error(`createLifecycleEvent: ${res.status} ${body.code} (request_id ${body.request_id})`); reported.set(order.number, body.id); return body.id; } ``` ```python title="Python" import os import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_SERVICE_KEY"] def record_order(order, reported): """reported: your own table of orders already sent (order number -> event id).""" if order["number"] in reported: return reported[order["number"]] # never send an order twice res = session.post( f"{BASE}/batteries/{order['battery_id']}/lifecycle-events", json={"type": order["type"], "occurred_on": order["done_on"], "description": f"Service code {order['code']}"}, timeout=30, ) body = res.json() if res.status_code != 201: raise SystemExit(f"createLifecycleEvent: {res.status_code} {body['code']} (request_id {body.get('request_id')})") reported[order["number"]] = body["id"] return body["id"] ``` The event has the source `api` and names the key in `api_key`; the audit log names the key as the actor. ## No duplicates `createLifecycleEvent` is not idempotent: every successful call records a new event. Keep a table of the orders you reported (order number, BatteryPass event id). After a timeout, look at [`listLifecycleEvents`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) of the unit (it needs `read`) before you send again: an event of the same type and day with your service code means the first call went through. ## What never flows No customer names, addresses or contact data, no order or invoice numbers, no technician names and no fault descriptions that identify people. The serial number links the event to the order in your ERP. Build the `description` from fixed codes, as above, not from the order's free text. ## Failure handling | Answer | Meaning | What to do | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | `400 validation_error` | Unknown `type` (also `condition_update`, which only `updateCondition` records), `occurred_on` before manufacture or in the future, a document that is not yours | Fix the mapping; do not retry unchanged. | | `401 unauthenticated` | Key missing, unknown or revoked | Check the secret; alert. | | `403 forbidden` | The key lacks the scope `condition` | Use the service key. | | `404 not_found` | The unit is not in your organisation | Check the serial number. | | `409 conflict` | The unit's model is archived | Record the event in the portal or not at all. | | `429 rate_limited` | Too many requests | Wait `Retry-After` seconds. | | `5xx`, timeout | Unknown outcome | Check `listLifecycleEvents` first, then retry. | --- # Product change leads to a new model version > A change to the product master in the ERP notifies the person responsible, who creates a new model version in BatteryPass; declared data is never written automatically. When engineering changes a battery (another cell supplier, a new capacity, a different mass), the product master in the ERP changes, and the battery passport needs a new **model version** with its own effective date. That change is declared data with legal weight. In this workflow **nothing is written to BatteryPass automatically**: the middleware notices the change and tells a person, who creates the new version in the portal and has its values approved. Keys cannot write declared data, so the pattern is also what BatteryPass enforces. ```mermaid sequenceDiagram participant ERP participant MW as Middleware participant BP as BatteryPass API participant P as Person responsible participant M as Member in the portal ERP->>MW: thin event: product number, kind of change MW->>ERP: read the changed product, check the event MW->>MW: passport data affected? otherwise drop MW->>BP: listBatteryModels (scope read) BP-->>MW: the model by GTIN or model identifier MW->>P: ticket or chat message with model and change P->>M: decides on a new model version M->>BP: create the version, enter values, have them approved M->>BP: check the change impact on existing units ``` **Trigger:** The ERP reports a change to the product or material master, for example as a business event or a webhook. **Key scopes:** read | # | Who | What | Operation | | - | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | 1 | ERP | Sends a thin event with the product number and the kind of change. | | | 2 | Middleware | Reads the changed product from the ERP and decides whether passport data is affected (for example cell chemistry, capacity, mass); price or text changes are dropped. | | | 3 | Middleware | Finds the battery model by GTIN or model identifier. | [`listBatteryModels`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-battery-models.md) (Scope: read) | | 4 | Middleware | Notifies the person responsible with the model and the change, for example by ticket or chat. | | | 5 | Member in the portal | Creates a new model version with its effective date in the portal, enters the changed values and has them approved; BatteryPass shows beforehand which units are affected. | | **Security** - The middleware needs the scope read only; keys cannot write declared data. - The middleware checks the ERP's webhooks the way the vendor provides (signature, validation handshake or secret URL). **Data minimisation** - The notification names the model and the changed characteristics, no supplier or price data. ## The trigger ERPs announce product changes in different ways; the [ERP pages](https://passport.kabin.sh/docs/en/integrations/erp/sap-s4hana-cloud) list what each system offers. Examples: business events of SAP S/4HANA through SAP Event Mesh, webhook subscriptions of Business Central, business events of Dynamics 365 Finance and Supply Chain Management, automation rules in Odoo. Most of them are thin: they say _what_ changed, not _how_. The middleware therefore: 1. checks the event as the vendor provides (a signature, a validation handshake, a client state or a secret URL) and drops what it cannot verify; 2. reads the current product from the ERP's API; 3. compares the attributes that matter for the passport (for example chemistry, rated capacity or energy, mass, cell or module supplier) with what it saw last time, and drops changes of prices, texts or logistics data. Without events, compare a nightly product export with the previous one: the result is the same. ## Find the battery model [`listBatteryModels`](https://passport.kabin.sh/docs/en/api-reference/catalog/list-battery-models) returns your models with `gtin` and `sku` (the model identifier). There is no filter by GTIN: page through the models once and keep a map in the middleware. ```bash title="cURL" curl -sS "$BATTERYPASS_URL/api/v1/battery-models?limit=200" \ -H "Authorization: Bearer $BATTERYPASS_READ_KEY" | jq '.items[] | select(.gtin == "04012345000023") | {id, name, sku}' ``` ```ts title="TypeScript" const BASE = `${process.env.BATTERYPASS_URL}/api/v1`; const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_READ_KEY}` }; type Model = { id: string; name: string; gtin?: string; sku?: string }; export async function modelsByGtin(): Promise> { const out = new Map(); let cursor: string | null = null; do { const query = new URLSearchParams({ limit: '200', ...(cursor ? { cursor } : {}) }); const res = await fetch(`${BASE}/battery-models?${query}`, { headers }); if (!res.ok) throw new Error(`listBatteryModels: ${res.status} ${(await res.json()).code}`); const page: { items: Model[]; next_cursor: string | null } = await res.json(); for (const m of page.items) if (m.gtin) out.set(m.gtin, m); cursor = page.next_cursor; } while (cursor); return out; } // Placeholder: open a ticket for the product owner. Never write to BatteryPass here. export function notify(model: Model, changed: string[]) { console.log(`New model version needed for ${model.name} (${model.id}): ${changed.join(', ')} changed in the ERP`); } ``` ```python title="Python" import os import requests BASE = os.environ["BATTERYPASS_URL"] + "/api/v1" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_READ_KEY"] def models_by_gtin(): out, cursor = {}, None while True: params = {"limit": 200, **({"cursor": cursor} if cursor else {})} res = session.get(f"{BASE}/battery-models", params=params, timeout=30) res.raise_for_status() page = res.json() out.update({m["gtin"]: m for m in page["items"] if m.get("gtin")}) cursor = page["next_cursor"] if not cursor: return out def notify(model, changed): """Placeholder: open a ticket for the product owner. Never write to BatteryPass here.""" print(f"New model version needed for {model['name']} ({model['id']}): {', '.join(changed)} changed in the ERP") ``` ## The person decides The notification names the model, what changed in the ERP and from which day it applies. In the portal a member then: 1. creates a new **model version** with its effective date, usually copying the values of the current version; 2. enters the changed values with their evidence, and has them approved (the four-eyes rule applies where your organisation has it switched on); 3. checks the **change impact**: which existing units would move to the new version by their manufacturing date, and which published passports would then need a new version; 4. approves the model version. The [change feed](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp) reports `model_version.approved`, and later `passport.republished` for units whose passports were published again, so the ERP can follow without asking. ## What never flows The notification carries the model and the changed characteristics. Supplier names and conditions, prices and the people who made the change stay in the ERP and the ticket system. ## Failure handling | Answer | Meaning | What to do | | ---------------------------------- | ----------------------------------------------------- | ------------------------------------------------ | | `400 validation_error` on `cursor` | A stale cursor | Start the listing again. | | `401 unauthenticated` | Key missing, unknown or revoked | Check the secret; alert. | | `403 forbidden` | The key lacks the scope `read` | Use the read key. | | No model with the GTIN | The product is not a battery model in BatteryPass yet | Notify anyway: the model may need to be created. | | `429 rate_limited` | Too many requests | Wait `Retry-After` seconds. | | `5xx`, timeout | Unknown outcome | Retry with backoff; reads are safe to repeat. | An ERP event that arrives twice produces two notifications at most; de-duplicate them by product number and change date in the ticket system. --- # abas ERP > What is known about connecting abas ERP to BatteryPass, and what still has to be clarified. > [!WARNING] > The facts on this page come from vendor marketing only and are not checked against developer documentation; there was > no public one. Confirm the interface and its sign-in with the vendor or your partner before a project. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for abas ERP and has no partnership with the > vendor; nothing here is a vendor approval. The vendor names a REST API and abas Connect. Whichever your installation offers, a middleware reads the serial numbers of finished production orders with a technical user that may only read, writes a file with the `generic-csv` preset and stages it as in [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). Change events were not checked; poll on a schedule until they are. ## Interfaces and how they were checked - **Vendor:** abas Software (Forterro) - **On-premise:** abas ERP - **Cloud:** abas ERP (hosted) From vendor marketing only: a REST API and abas Connect are named; there was no public developer documentation. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ------------------------------------------------------------------------- | -------- | ------- | ------ | -------------------------------------------- | ---------------------------------------------- | | [`REST API`](https://abas-erp.com/en/product/connectivity-iot) (REST API) | REST | Token | none | Product/material master, Batch/serial number | community or marketing source only, 24/10/2026 | **Notes** - `REST API`: Sign-in not checked. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition --- # Microsoft Dynamics 365 Business Central > How Business Central hands serial numbers to BatteryPass through its API and a custom API page. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Business Central and has no > partnership with Microsoft; nothing here is a vendor approval. The standard API v2.0 returns items and item ledger entries, but `itemLedgerEntries` has no serial or lot number fields. Serial numbers therefore need a custom AL API page, written by your partner or your own IT, that returns item ledger entries with their serial number. The `business-central-serials` preset matches its columns (and the column names of the Excel export of the user interface, German and English); it assumes the model identifier in BatteryPass is the item number. The middleware signs in through Microsoft Entra ID as a service (client credentials). To notice new entries it can subscribe to webhooks with `POST /api/v2.0/subscriptions`: Business Central checks the address with a validation handshake, sends thin notifications (the resource and the kind of change) with your `clientState`, and lets a subscription expire after three days, so renew it in time. The notifications are not signed: check the `clientState` and read the changed resource through the API before acting. From there the steps are those of [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) and [passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp). ## Interfaces and how they were checked - **Vendor:** Microsoft - **Cloud:** Business Central online (API v2.0) - **On-premise:** Business Central on-premises (API v2.0) The standard API returns item ledger entries without serial and lot numbers; that needs a custom AL API page. Webhook subscriptions expire after three days and must be renewed. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | --------------------- | --------------------------------------------- | ----------------------------------------------- | | [`API v2.0`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/resources/dynamics_itemledgerentry) (Standard API (items, item ledger entries, sales documents)) | OData V4 | OAuth 2.0 client credentials | none | Product/material master, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | [`Webhook subscriptions (/api/v2.0/subscriptions)`](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-subscriptions) (Change notifications) | REST | OAuth 2.0 client credentials | Webhooks (not signed) | Product/material master, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | `Custom AL API page` (Custom API page for item ledger entries with serial numbers) | OData V4 | OAuth 2.0 client credentials | none | Batch/serial number | checked in the vendor documentation, 24/10/2026 | **Notes** - `API v2.0`: Sign-in through Microsoft Entra ID (service to service); itemLedgerEntries has no serial or lot number fields. - `Webhook subscriptions (/api/v2.0/subscriptions)`: On creation Business Central checks the address with a validation handshake. Notifications are thin (the resource and the kind of change) and carry the subscription's clientState, but no signature. - `Custom AL API page`: An extension the partner or your own IT writes; it returns the columns of the business-central-serials preset. **Import presets: Business Central item ledger with serial numbers** (`business-central-serials`): Item ledger entries from Microsoft Dynamics 365 Business Central with their serial number, as a custom AL API page returns them (the standard itemLedgerEntries API has no serial numbers). Column names of the API page and of the user interface's Excel export (German and English). Assumes that the model identifier is the item number. | Column in the export | Field in BatteryPass | | ------------------------------------------------ | -------------------------------------------------------- | | `itemNo` (also Item No., Artikelnr.) | Model identifier / SKU `identification.model_identifier` | | `serialNo` (also Serial No., Seriennr.) | Serial number `identification.serial_number` | | `postingDate` (also Posting Date, Buchungsdatum) | Manufacturing date `identification.manufacturing_date` | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # Microsoft Dynamics 365 Finance and Supply Chain Management > How Dynamics 365 Finance and Supply Chain Management works with BatteryPass through data entities and business events. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Dynamics 365 and has no partnership > with Microsoft; nothing here is a vendor approval. The data entities under `/data` (OData V4) expose products, serial numbers, production, deliveries and returns. The middleware signs in through Microsoft Entra ID with a client secret or, better, a certificate, as an application user with read rights to the entities it needs. Business events announce changes, for example a finished production order, to Azure Service Bus, Event Grid, Event Hubs, an HTTPS endpoint or Power Automate. According to Microsoft they are no way to export data and may arrive out of order. Treat each event as a hint: read the data through OData afterwards and process it idempotently. Typical fits are [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) with the `generic-csv` preset, [returns and repairs](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs) from service orders, and [condition data](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms) where a service process records measurements. ## Interfaces and how they were checked - **Vendor:** Microsoft - **Cloud:** Finance and Supply Chain Management (formerly Finance and Operations) Business events announce changes, but according to Microsoft they are no way to export data and may arrive out of order: after the event the middleware reads the data through OData. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | | [`OData data entities (/data)`](https://learn.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/data-entities/odata) (Data entities (products, serials, production, deliveries, returns)) | OData V4 | OAuth 2.0 client credentials | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue, Return | checked in the vendor documentation, 24/10/2026 | | [`Business events`](https://learn.microsoft.com/en-us/dynamics365/fin-ops-core/dev-itpro/business-events/home-page) (Business events) | REST | OAuth 2.0 client credentials | Business events (not signed) | Product/material master, Production order, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | **Notes** - `OData data entities (/data)`: Sign-in through Microsoft Entra ID with a client secret or a certificate. - `Business events`: Endpoints include Azure Service Bus, Event Grid, Event Hubs, HTTPS and Power Automate; no ordering guarantee. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Condition data from BMS and service](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms.md): A battery management system or a service tool reports measurements such as capacity fade or cycles; BatteryPass accepts newer measurements and publishes according to the organisation's policy. Key scopes: read, condition - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # Microsoft Dynamics NAV 2018 > How Dynamics NAV 2018 can supply serial numbers until an upgrade, and why new integrations target Business Central. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Dynamics NAV and has no partnership > with Microsoft; nothing here is a vendor approval. > [!WARNING] > According to Microsoft's lifecycle page, extended support for Dynamics NAV 2018 ends on 11 January 2028. Build new > integrations against the Business Central API (see [Business Central](https://passport.kabin.sh/docs/en/integrations/erp/dynamics-365-business-central)) > and keep what you build for NAV small. NAV 2018 publishes pages, codeunits and queries as SOAP and OData web services. The middleware signs in with NavUserPassword or the user's web service access key; use a technical user with read rights only. NAV has no change events, so the middleware reads new serial numbers on a schedule. Until the upgrade, a file export is often enough: the middleware (or a scheduled report) writes the serial numbers with the column names of the `generic-csv` preset, and the file is staged as in [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). ## Interfaces and how they were checked - **Vendor:** Microsoft - **On-premise:** Dynamics NAV 2018 According to Microsoft, extended support ends on 11 January 2028. New integrations should target the Business Central API; until the upgrade, a file export with the generic preset is enough. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------- | ------ | ------------------------------------------------------------------ | ----------------------------------------------- | | [`SOAP web services`](https://learn.microsoft.com/en-us/previous-versions/dynamicsnav-2018-developer/Web-Services) (Published pages and codeunits) | SOAP | Basic auth, API key | none | Product/material master, Batch/serial number, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | [`OData web services`](https://learn.microsoft.com/en-us/lifecycle/products/dynamics-nav-2018) (Published pages and queries) | OData V4 | Basic auth, API key | none | Product/material master, Batch/serial number, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | **Notes** - `SOAP web services`: Sign-in with NavUserPassword or the user's web service access key. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition --- # Epicor Kinetic > How Epicor Kinetic could supply serial numbers to BatteryPass through REST API v2. > [!WARNING] > The facts on this page come from community sources only and are not checked against Epicor documentation. Confirm the > interfaces and sign-in with Epicor or your partner before a project. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Epicor Kinetic and has no partnership > with Epicor; nothing here is a vendor approval. According to community sources, REST API v2 of Kinetic exposes the business objects over REST and needs an API key in the `x-api-key` header in addition to the user sign-in. Never send that key as a query parameter: URLs end up in logs. Use a technical user with read rights only, and keep both secrets in a vault. A middleware reads the serial numbers of finished jobs and writes a file with the `generic-csv` preset for [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). Change events were not checked; poll on a schedule until they are. ## Interfaces and how they were checked - **Vendor:** Epicor - **Cloud:** Epicor Kinetic (REST API v2) - **On-premise:** Epicor Kinetic (REST API v2) From community sources only: REST API v2 needs an API key in the x-api-key header in addition to the user sign-in. Never send the key as a query parameter. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | --------------------------------------------------------------------------------------------------------------------- | -------- | ------------------- | ------ | ------------------------------------------------------------------------------------ | ---------------------------------------------- | | [`REST API v2`](https://www.epiusers.help/t/rest-interface-header-key-value-pairs/44309) (Business objects over REST) | REST | API key, Basic auth | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue | community or marketing source only, 24/10/2026 | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # IFS Cloud > How IFS Cloud could supply serial numbers to BatteryPass through its OData projections. > [!WARNING] > The facts on this page come from community sources only and are not checked against IFS documentation. Confirm the > interfaces, sign-in and entities with IFS or your partner before a project. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for IFS Cloud and has no partnership with > IFS; nothing here is a vendor approval. According to community sources, the APIs of IFS Cloud are OData projections of the applications, and sign-in uses OAuth 2.0. A middleware would read the serial numbers of finished shop orders through such a projection, with a service user that may only read, and write a file with the `generic-csv` preset for [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). Change events were not checked; until they are, the middleware polls on a schedule. IFS is often used for service processes as well, so [returns and repairs](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs) and [condition data](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms) may fit too. ## Interfaces and how they were checked - **Vendor:** IFS - **Cloud:** IFS Cloud - **On-premise:** IFS Cloud (remote deployment) From community sources only: the APIs are OData projections and sign-in uses OAuth 2.0. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ---------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | ------ | -------------------------------------------------------------- | ---------------------------------------------- | | [`Projections (OData)`](https://community.ifs.com/questions/37795) (OData projections of the applications) | OData V4 | OAuth 2.0 client credentials | none | Product/material master, Batch/serial number, Production order | community or marketing source only, 24/10/2026 | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Condition data from BMS and service](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms.md): A battery management system or a service tool reports measurements such as capacity fade or cycles; BatteryPass accepts newer measurements and publishes according to the organisation's policy. Key scopes: read, condition - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # Infor CloudSuite (M3, LN) > How Infor M3 and Infor LN reach BatteryPass through the ION API Gateway. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Infor and has no partnership with > Infor; nothing here is a vendor approval. > [!WARNING] > The LN OData entities and the BOD messages below are not checked against Infor's documentation (marked "not checked" > in the table). Confirm protocol versions and entities with Infor or your partner before you build on them. Every call goes through the ION API Gateway with OAuth 2.0 client credentials. The credentials come in an `.ionapi` file; treat it as a secret like the BatteryPass key: keep it in a vault, never in a repository, and give each integration its own. In M3 the middleware calls MI programs through the gateway; in LN it reads business data entities. For [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) the middleware reads the serial numbers of finished orders and writes a file with the `generic-csv` preset. Infor ION can also publish Business Object Documents (BODs) when data changes; use them as triggers and read the details through the gateway. ## Interfaces and how they were checked - **Vendor:** Infor - **Cloud:** Infor M3 and Infor LN on Infor OS - **On-premise:** Infor M3 and Infor LN with Infor OS on-premises Every call goes through the ION API Gateway; the credentials are in an .ionapi file, which is to be treated as a secret. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ---------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | | [`ION API Gateway`](https://docs.infor.com/inforos/2026.x/en-us/useradminlib_cloud/apigatewayag_cloud/wxo1761727799964.html) (Access to the APIs of M3 and LN) | REST | OAuth 2.0 client credentials | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue, Return | checked in the vendor documentation, 24/10/2026 | | `M3 MI programs` (M3 programs through the ION API Gateway) | REST | OAuth 2.0 client credentials | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | `LN OData (BDE)` (LN business data entities) | OData V4 | OAuth 2.0 client credentials | none | Product/material master, Batch/serial number, Production order | not checked | | `Business Object Documents (BODs)` (Messages through Infor ION) | BOD (Infor ION) | OAuth 2.0 client credentials | Business events (not signed) | Product/material master, Production order, Delivery/goods issue | not checked | **Notes** - `LN OData (BDE)`: Protocol version and entities not checked. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Condition data from BMS and service](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms.md): A battery management system or a service tool reports measurements such as capacity fade or cycles; BatteryPass accepts newer measurements and publishes according to the organisation's policy. Key scopes: read, condition - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # Odoo > How Odoo 19 hands lots and serial numbers to BatteryPass through the JSON-2 API. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Odoo and has no partnership with Odoo > S.A.; nothing here is a vendor approval. Odoo 19 offers the JSON-2 API: `POST /json/2//` with an API key as bearer token. Serial numbers are records of `stock.lot`, manufacturing orders of `mrp.production`. The `odoo-stock-lot` preset maps the lot or serial number and the product's internal reference; Odoo keeps no manufacturing date on the lot, so the middleware adds the column "Manufacturing Date", for example from the end date of the manufacturing order. API keys carry an expiry date: put their rotation in your operations plan, as you do for the BatteryPass key. XML-RPC and JSON-RPC are deprecated and go away with Odoo 22 (fall 2028) and Odoo Online 21.1; do not build new integrations on them. Automation rules can send a record to a URL as an outgoing webhook. The payload is not signed, so treat it as a hint: the middleware reads the record through the JSON-2 API before it acts. From there, follow [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). ## Interfaces and how they were checked - **Vendor:** Odoo S.A. - **Cloud:** Odoo Online and Odoo.sh 19 - **On-premise:** Odoo 19 Community and Enterprise Odoo 19 offers the JSON-2 API with API keys as bearer tokens; XML-RPC and JSON-RPC are deprecated and go away with Odoo 22 (fall 2028) and Odoo Online 21.1. API keys have an expiry date: rotation belongs in the operations plan. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------- | --------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | | [`JSON-2 API (/json/2//)`](https://www.odoo.com/documentation/19.0/developer/reference/external_api.html) (Every model, for example product.product, stock.lot, mrp.production) | REST | API key | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue, Return | checked in the vendor documentation, 24/10/2026 | | [`Webhooks (automation rules)`](https://www.odoo.com/documentation/19.0/applications/studio/automated_actions/webhooks.html) (Outgoing webhooks from automation rules) | REST | API key | Webhooks (not signed) | Product/material master, Batch/serial number, Production order | checked in the vendor documentation, 24/10/2026 | | [`XML-RPC and JSON-RPC (deprecated)`](https://www.odoo.com/documentation/19.0/developer/reference/external_api.html) (Older external API) | XML-RPC | API key, Session | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue, Return | checked in the vendor documentation, 24/10/2026 | **Notes** - `Webhooks (automation rules)`: The payload is not signed; for incoming webhooks the secret URL is the protection. - `XML-RPC and JSON-RPC (deprecated)`: Do not use for new integrations. **Import presets: Odoo lots and serial numbers (stock.lot)** (`odoo-stock-lot`): An export of the stock.lot model from Odoo with the product's internal reference. Odoo keeps no manufacturing date on the lot; the middleware adds the column "Manufacturing Date", for example from the end date of the manufacturing order. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `Lot/Serial Number` (also name, Los-/Seriennummer) | Serial number `identification.serial_number` | | `Product/Internal Reference` (also product_id/default_code, Produkt/Interne Referenz) | Model identifier / SKU `identification.model_identifier` | | `Manufacturing Date` (also date_finished, Herstelldatum) | Manufacturing date `identification.manufacturing_date` | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # Oracle Fusion Cloud Supply Chain Management > How Oracle Fusion Cloud SCM provides work order serial numbers to BatteryPass through its REST API and Oracle Integration. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Oracle Fusion and has no partnership > with Oracle; nothing here is a vendor approval. The serial numbers of a work order are a child resource of the work order in the REST API: `workOrders/{WorkOrderId}/child/WorkOrderSerialNumber`. After a work order is completed, the middleware reads them together with the item and the completion date and writes a file with the `generic-csv` preset for [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). Events come through the ERP Cloud Adapter of Oracle Integration, which subscribes to business events of the application and starts an integration flow. Use the event as a trigger and read the data through the REST API afterwards. The REST API accepts basic authentication and OAuth 2.0; prefer OAuth 2.0 with a dedicated integration user that may only read work orders and items. ## Interfaces and how they were checked - **Vendor:** Oracle - **Cloud:** Oracle Fusion Cloud SCM (REST API 11.13.18.05) Serial numbers of work orders are a child resource of the work order. Events come through the ERP Cloud Adapter of Oracle Integration. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- | | [`REST API (fscmRestApi)`](https://docs.oracle.com/en/cloud/saas/supply-chain-and-manufacturing/25d/fasrp/op-workorders-workorderid-child-workorderserialnumber-action-generateserialnumbers-post.html) (Items, work orders and their serial numbers, shipments) | REST | Basic auth, OAuth 2.0 client credentials | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | [`Oracle Integration ERP Cloud Adapter`](https://docs.oracle.com/en/cloud/paas/application-integration/erp-adapter/) (Business events to Oracle Integration) | REST | OAuth 2.0 client credentials | Business events (not signed) | Product/material master, Production order, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | **Notes** - `REST API (fscmRestApi)`: The serial numbers of a work order are at workOrders/{WorkOrderId}/child/WorkOrderSerialNumber. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # Oracle NetSuite > How Oracle NetSuite hands inventory numbers to BatteryPass through SuiteTalk REST and SuiteQL. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for NetSuite and has no partnership with > Oracle; nothing here is a vendor approval. Serial and lot numbers live in the `inventorynumber` record. The middleware reads them with SuiteQL through the REST web services, or exports a saved search as CSV; the `netsuite-inventory-numbers` preset maps the number and the item (it assumes the model identifier in BatteryPass is the item name). NetSuite keeps no manufacturing date on the inventory number, so the middleware adds it, for example from the work order. For sign-in, new integrations should use OAuth 2.0 client credentials, a JWT signed with a certificate you upload to NetSuite. Partner sources name release 2027.1 as the end for new integrations with token-based authentication (TBA); Oracle's own pages did not confirm that date when we checked, so plan for OAuth 2.0 either way. NetSuite sends no events by itself. A user event script (`afterSubmit` with the `N/https` module) can call your middleware after a record is saved; signing, retries and error handling are then up to that script. Polling with a SuiteQL query on the last modification date is the simpler alternative. ## Interfaces and how they were checked - **Vendor:** Oracle - **Cloud:** NetSuite (SuiteTalk REST web services) For new integrations use OAuth 2.0 client credentials (a certificate-signed JWT) instead of token-based authentication: partner sources name 2027.1 as the end for new TBA integrations; Oracle's pages did not confirm it. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------- | --------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | | [`SuiteTalk REST web services`](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/chapter_1540391670.html) (Records (items, work orders, fulfilments, returns)) | REST | OAuth 2.0 client credentials, Token | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue, Return | checked in the vendor documentation, 24/10/2026 | | [`SuiteQL`](https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157909186990.html) (Queries, for example of the inventorynumber record) | REST | OAuth 2.0 client credentials, Token | none | Batch/serial number | checked in the vendor documentation, 24/10/2026 | | `SuiteScript afterSubmit with N/https` (Own notification after a record is saved) | REST | OAuth 2.0 client credentials | Webhooks (not signed) | Product/material master, Batch/serial number, Production order | checked in the vendor documentation, 24/10/2026 | **Notes** - `SuiteScript afterSubmit with N/https`: A user event script calls an HTTPS address after the save; signing and retries are up to the script. **Import presets: NetSuite saved search of inventory numbers** (`netsuite-inventory-numbers`): A saved search of the inventory number record (inventorynumber) in Oracle NetSuite, exported as CSV or read with SuiteQL. Assumes that the model identifier is the item name. The middleware adds the manufacturing date, for example from the work order. | Column in the export | Field in BatteryPass | | -------------------------------------------------------------------- | -------------------------------------------------------- | | `Number` (also Inventory Number, inventorynumber, Serial/Lot Number) | Serial number `identification.serial_number` | | `Item` (also itemid, Item Name) | Model identifier / SKU `identification.model_identifier` | | `Manufacturing Date` (also Herstelldatum) | Manufacturing date `identification.manufacturing_date` | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # proALPHA ERP > Connecting proALPHA to BatteryPass with a file export until an interface is confirmed. > [!WARNING] > Not checked: a REST interface with an OpenAPI description is mentioned by third parties only. Until the vendor confirms > it, plan with a file export. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for proALPHA and has no partnership with the > vendor; nothing here is a vendor approval. A file export is the safe way: a scheduled report or a small middleware writes the serial numbers of finished production with the column names of the `generic-csv` preset (GTIN, serial number, manufacturing date, facility, model version, battery status). The middleware uploads the file with a key that has the `import` scope only; members commit and approve it in the portal, as in [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). ## Interfaces and how they were checked - **Vendor:** proALPHA - **On-premise:** proALPHA ERP Not checked: a REST interface with an OpenAPI description is mentioned by third parties only. Until the vendor confirms it, a file export with the generic preset is the safe way. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ------------------------------------------------- | -------- | ------- | ------ | -------------------------------------------- | ----------- | | `REST interface` (REST interface (not confirmed)) | REST | Token | none | Product/material master, Batch/serial number | not checked | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read --- # Sage X3 > What is known about connecting Sage X3 to BatteryPass, and what still has to be clarified. > [!WARNING] > Not checked: Sage names a GraphQL API for X3, but its reference is open to partners only, and we could not confirm > sign-in or objects. Clarify the interface with Sage or your partner before a project. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Sage X3 and has no partnership with > Sage; nothing here is a vendor approval. Whatever interface your X3 offers, the shape on the BatteryPass side stays the same: a middleware reads the serial numbers of finished production, writes a file with the `generic-csv` preset and stages it as in [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production); members commit and approve it in the portal. Until the interface is clarified, a scheduled export of a file with these columns is a safe start. ## Interfaces and how they were checked - **Vendor:** Sage - **Cloud:** Sage X3 - **On-premise:** Sage X3 Not checked: Sage names a GraphQL API; the reference is open to partners only. Clarify with the vendor before the project. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | -------------------------------------------------------------------- | -------- | ---------------------------- | ------ | -------------------------------------------- | ----------- | | [`GraphQL API`](https://developer.sage.com/x3/graphql) (GraphQL API) | GraphQL | OAuth 2.0 client credentials | none | Product/material master, Batch/serial number | not checked | **Notes** - `GraphQL API`: Sign-in and objects not checked. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition --- # SAP Business One > How SAP Business One feeds serial numbers to BatteryPass through the Service Layer. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for SAP Business One and has no > partnership with SAP; nothing here is a vendor approval. The Service Layer is the way in: OData V3 under `/b1s/v1` and OData V4 under `/b1s/v2`. It works with sessions: the middleware signs in with `POST /Login`, keeps the session cookie and signs in again when the session ends. Use a technical Business One user with read access to items, serial numbers and production orders only. Webhooks are not documented for the Service Layer, so the middleware polls: for example every few minutes for serial numbers created since the last run. It writes them into a file with the `generic-csv` preset (GTIN, serial number, manufacturing date, facility) and stages it as in [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). Keep the Service Layer itself inside your network; only the middleware talks to BatteryPass. ## Interfaces and how they were checked - **Vendor:** SAP - **On-premise:** SAP Business One with Service Layer (SQL Server and SAP HANA) The Service Layer works with sessions: the middleware signs in with POST /Login and keeps the session cookie. Webhooks are not documented for the Service Layer; the middleware finds changes by polling. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ------ | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | | [`Service Layer /b1s/v1`](https://learning.sap.com/courses/leveraging-the-sap-business-one-service-layer/logging-in-and-logging-out-using-service-layer-api) (Service Layer (OData V3)) | OData V3 | Session | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue, Return | checked in the vendor documentation, 24/10/2026 | | [`Service Layer /b1s/v2`](https://learning.sap.com/courses/leveraging-the-sap-business-one-service-layer/logging-in-and-logging-out-using-service-layer-api) (Service Layer (OData V4)) | OData V4 | Session | none | Product/material master, Batch/serial number, Production order, Delivery/goods issue, Return | checked in the vendor documentation, 24/10/2026 | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # SAP S/4HANA Cloud Public Edition > How SAP S/4HANA Cloud Public Edition hands serial numbers to BatteryPass and takes passport links back. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for SAP S/4HANA Cloud and has no > partnership with SAP; nothing here is a vendor approval. In the Public Edition every integration talks to the released APIs of the SAP Business Accelerator Hub through a communication arrangement of its own. Give the BatteryPass middleware exactly the APIs it needs: the serial number API, production order confirmations or goods movements for [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production), and the product API when a [product change](https://passport.kabin.sh/docs/en/integrations/workflows/product-change) should reach the product owner. Serial numbers come from `CE_API_MATERIALSERIALNUMBER_0001`. SAP Note 3772443 describes a defect with serialisation level 1: a create answers `201` but creates nothing. Reading is not affected, so let serial numbers be created in production (or with IQ01) and only read them from the middleware. The `sap-serial-numbers` preset maps the material number, the serial number and the posting date of the goods receipt; it assumes the battery model's model identifier in BatteryPass is the SAP material number. Changes reach the middleware as CloudEvents through SAP Event Mesh, for example `sap.s4.beh.product.v1.Product.Changed.v1`. The events are thin: they carry the key, and the middleware reads the product afterwards. For the way back (Digital Link and passport status on the serial number record) the middleware follows the BatteryPass [change feed](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp). ## Interfaces and how they were checked - **Vendor:** SAP - **Cloud:** Public Edition Each integration gets a communication arrangement of its own with exactly the APIs it needs. The middleware usually runs in SAP Integration Suite or another iPaaS. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ----------------------------------------------------------- | ---------------------------- | ----------------------- | ----------------------------------------------- | | [`CE_PRODUCT_0002`](https://api.sap.com/api/CE_PRODUCT_0002/overview) (Product master) | OData V4 | Basic auth, X.509 certificate, OAuth 2.0 client credentials | none | Product/material master | checked in the vendor documentation, 24/10/2026 | | [`CE_API_MATERIALSERIALNUMBER_0001`](https://api.sap.com/api/CE_API_MATERIALSERIALNUMBER_0001/overview) (Serial numbers) | OData V4 | Basic auth, X.509 certificate, OAuth 2.0 client credentials | none | Batch/serial number | checked in the vendor documentation, 24/10/2026 | | [`CE_PRODUCTIONORDER_0001`](https://api.sap.com/api/CE_PRODUCTIONORDER_0001/overview) (Production orders) | OData V4 | Basic auth, X.509 certificate, OAuth 2.0 client credentials | none | Production order | checked in the vendor documentation, 24/10/2026 | | [`API_PROD_ORDER_CONFIRMATION_2_SRV`](https://api.sap.com/api/API_PROD_ORDER_CONFIRMATION_2_SRV/overview) (Production order confirmations) | OData V2 | Basic auth, X.509 certificate, OAuth 2.0 client credentials | none | Production order | checked in the vendor documentation, 24/10/2026 | | [`API_OUTBOUND_DELIVERY_SRV_0002`](https://api.sap.com/api/API_OUTBOUND_DELIVERY_SRV_0002/overview) (Outbound deliveries) | OData V2 | Basic auth, X.509 certificate, OAuth 2.0 client credentials | none | Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | [`API_MATERIAL_DOCUMENT_SRV`](https://api.sap.com/api/API_MATERIAL_DOCUMENT_SRV/overview) (Goods movements (goods receipt, goods issue)) | OData V2 | Basic auth, X.509 certificate, OAuth 2.0 client credentials | none | Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | [`API_CUSTOMER_RETURN_SRV`](https://api.sap.com/api/API_CUSTOMER_RETURN_SRV/overview) (Customer returns) | OData V2 | Basic auth, X.509 certificate, OAuth 2.0 client credentials | none | Return | checked in the vendor documentation, 24/10/2026 | | `sap.s4.beh.product.v1.Product.Changed.v1` (Product changed event through SAP Event Mesh) | REST | OAuth 2.0 client credentials | Business events (not signed) | Product/material master | checked in the vendor documentation, 24/10/2026 | **Notes** - `CE_PRODUCT_0002`: The older API_PRODUCT_SRV (OData V2) is meant only for product texts. - `CE_API_MATERIALSERIALNUMBER_0001`: SAP Note 3772443: with serialisation level 1, a create answers 201 but creates nothing. Reading is not affected; serial numbers are then created through production or IQ01. - `sap.s4.beh.product.v1.Product.Changed.v1`: CloudEvents with a thin payload (the product key); the middleware reads the product through the API afterwards. **Import presets: SAP serial number list** (`sap-serial-numbers`): A list of serial numbers from SAP S/4HANA as a middleware assembles it from the serial number API or the goods receipt from production. Assumes that the battery model's model identifier in BatteryPass is the SAP material number. The posting date of the goods receipt stands in for the manufacturing date; the plant stays unmapped (choose the facility in the import options). | Column in the export | Field in BatteryPass | | -------------------------------------------------- | -------------------------------------------------------- | | `Material` (also Product, Materialnummer, Produkt) | Model identifier / SKU `identification.model_identifier` | | `SerialNumber` (also Serial Number, Serialnummer) | Serial number `identification.serial_number` | | `PostingDate` (also Posting Date, Buchungsdatum) | Manufacturing date `identification.manufacturing_date` | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Condition data from BMS and service](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms.md): A battery management system or a service tool reports measurements such as capacity fade or cycles; BatteryPass accepts newer measurements and publishes according to the organisation's policy. Key scopes: read, condition - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # SAP S/4HANA (on-premise and Private Edition) > How an on-premise SAP S/4HANA or a Private Edition hands serial numbers to BatteryPass. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for SAP S/4HANA and has no partnership > with SAP; nothing here is a vendor approval. The `OP_` APIs of the SAP Business Accelerator Hub mirror the cloud APIs: `OP_API_MATERIALSERIALNUMBER_0001` for serial numbers, `OP_API_BATCH_SRV_0001` for batches and `OP_API_PRODUCTION_ORDER_2_SRV_0001` for production orders. The middleware usually reaches the system through SAP Cloud Connector or a reverse proxy in the plant network; the SAP system itself is never exposed to the internet. Only the middleware talks to BatteryPass, with its own key. For [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production) the middleware reads the serial numbers of a batch and writes a file with the `sap-serial-numbers` preset (material number, serial number, posting date of the goods receipt). It assumes the model identifier in BatteryPass is the SAP material number. Changes to the material master can arrive as `MATMAS` IDocs through ALE change pointers, or as RAP business events through SAP Event Mesh. `SERDAT` is the ALE serialisation message, not a serial number IDoc: serial numbers come through the OData APIs. Passport links flow back as in the [passport links workflow](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp). ## Interfaces and how they were checked - **Vendor:** SAP - **On-premise:** On-premise and Private Edition with the OP\_ APIs of the SAP Business Accelerator Hub The OP\_ APIs mirror the cloud APIs; the middleware usually reaches the system through SAP Cloud Connector or a reverse proxy in the plant network, never directly from the internet. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | ------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------- | ---------------------------- | ----------------------------------------- | ----------------------------------------------- | | [`OP_API_PRODUCT_SRV_0001`](https://api.sap.com/api/OP_API_PRODUCT_SRV_0001/overview) (Product master) | OData V2 | Basic auth, X.509 certificate | none | Product/material master | checked in the vendor documentation, 24/10/2026 | | [`OP_API_MATERIALSERIALNUMBER_0001`](https://api.sap.com/api/OP_API_MATERIALSERIALNUMBER_0001/overview) (Serial numbers) | OData V4 | Basic auth, X.509 certificate | none | Batch/serial number | checked in the vendor documentation, 24/10/2026 | | [`OP_API_BATCH_SRV_0001`](https://api.sap.com/api/OP_API_BATCH_SRV_0001/overview) (Batches) | OData V2 | Basic auth, X.509 certificate | none | Batch/serial number | checked in the vendor documentation, 24/10/2026 | | [`OP_API_PRODUCTION_ORDER_2_SRV_0001`](https://api.sap.com/api/OP_API_PRODUCTION_ORDER_2_SRV_0001/overview) (Production orders) | OData V2 | Basic auth, X.509 certificate | none | Production order | checked in the vendor documentation, 24/10/2026 | | [`OP_CUSTOMERRETURN_0001`](https://api.sap.com/api/OP_CUSTOMERRETURN_0001/overview) (Customer returns) | OData V4 | Basic auth, X.509 certificate | none | Return | checked in the vendor documentation, 24/10/2026 | | `MATMAS` (IDoc for the material master) | IDoc | Basic auth, X.509 certificate | Business events (not signed) | Product/material master | checked in the vendor documentation, 24/10/2026 | | `RAP business events` (Business events of the RAP model) | REST | OAuth 2.0 client credentials, X.509 certificate | Business events (not signed) | Product/material master, Production order | checked in the vendor documentation, 24/10/2026 | **Notes** - `MATMAS`: SERDAT is the ALE serialisation message, not a serial number IDoc; serial numbers come through the OData APIs. ALE distribution sends MATMAS through change pointers whenever a material changes. - `RAP business events`: Through SAP Event Mesh or Advanced Event Mesh; thin events, the middleware reads the data afterwards. **Import presets: SAP serial number list** (`sap-serial-numbers`): A list of serial numbers from SAP S/4HANA as a middleware assembles it from the serial number API or the goods receipt from production. Assumes that the battery model's model identifier in BatteryPass is the SAP material number. The posting date of the goods receipt stands in for the manufacturing date; the plant stays unmapped (choose the facility in the import options). | Column in the export | Field in BatteryPass | | -------------------------------------------------- | -------------------------------------------------------- | | `Material` (also Product, Materialnummer, Produkt) | Model identifier / SKU `identification.model_identifier` | | `SerialNumber` (also Serial Number, Serialnummer) | Serial number `identification.serial_number` | | `PostingDate` (also Posting Date, Buchungsdatum) | Manufacturing date `identification.manufacturing_date` | **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Condition data from BMS and service](https://passport.kabin.sh/docs/en/integrations/workflows/condition-from-bms.md): A battery management system or a service tool reports measurements such as capacity fade or cycles; BatteryPass accepts newer measurements and publishes according to the organisation's policy. Key scopes: read, condition - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # weclapp > How weclapp hands articles and serial numbers to BatteryPass through its REST API. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for weclapp and has no partnership with > weclapp; nothing here is a vendor approval. The REST API of weclapp takes the API token in the `AuthenticationToken` header. The token belongs to a user, so create a technical user with narrow rights for the integration and keep its token in a vault next to the BatteryPass key. The middleware reads articles and serial numbers of finished production, writes a file with the `generic-csv` preset and stages it as in [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). Webhooks were not checked; poll on a schedule unless you confirm them. Passport links can go back to the article or serial number record as in [passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp). ## Interfaces and how they were checked - **Vendor:** weclapp - **Cloud:** weclapp (REST API v1) The API token goes in the AuthenticationToken header; it belongs to a user, so create a technical user with narrow rights. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | -------------------------------------------------------------------------------- | -------- | ------- | ------ | ------------------------------------------------------------------ | ----------------------------------------------- | | [`REST API`](https://www.weclapp.com/api/) (Articles, serial numbers, shipments) | REST | API key | none | Product/material master, Batch/serial number, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | **Notes** - `REST API`: Webhooks not checked. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # Xentral > How Xentral hands products and deliveries to BatteryPass through its REST API and webhooks. > [!NOTE] > This page describes an integration pattern. BatteryPass ships no connector for Xentral and has no partnership with > Xentral; nothing here is a vendor approval. Xentral's REST API signs in with personal access tokens. Create a technical user for the integration, so the token does not stop working when a colleague leaves, and keep it in a vault. Webhooks can be managed through the API and tell the middleware about new products or deliveries. Signing of the deliveries was not checked, so the middleware checks sender and content and reads the record through the API before it acts. Serial numbers of finished production go into a file with the `generic-csv` preset and are staged as in [units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production). ## Interfaces and how they were checked - **Vendor:** Xentral ERP Software - **Cloud:** Xentral (REST API) Sign-in with personal access tokens; create a technical user for the integration. An integration pattern, not a connector: BatteryPass ships no ERP connector and holds no vendor certification. | Interface | Protocol | Sign-in | Events | Objects | Checked | | -------------------------------------------------------------------------------------------------- | -------- | ------- | --------------------- | ------------------------------------------------------------------ | ----------------------------------------------- | | [`REST API`](https://developer.xentral.com/reference/authentication) (Products, stock, deliveries) | REST | Token | none | Product/material master, Batch/serial number, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | | [`Webhooks`](https://developer.xentral.com/reference/webhooks) (Manage and receive webhooks) | REST | Token | Webhooks (not signed) | Product/material master, Delivery/goods issue | checked in the vendor documentation, 24/10/2026 | **Notes** - `Webhooks`: Signing of deliveries not checked; the middleware checks sender and content before it acts. **Import presets: Generic CSV file** (`generic-csv`): For any ERP and for files a middleware writes itself: GTIN, serial number, manufacturing date, facility (name or GLN), model version and battery status, with German and English column names. | Column in the export | Field in BatteryPass | | ------------------------------------------------------------- | --------------------------------------------------------- | | `GTIN` (also EAN, GTIN-13, GTIN-14) | GTIN (GS1) `identification.gtin` | | `Serial number` (also Seriennummer, serial_number) | Serial number `identification.serial_number` | | `Manufacturing date` (also Herstelldatum, manufacturing_date) | Manufacturing date `identification.manufacturing_date` | | `Facility` (also Produktionsstätte, Facility GLN, Werk-GLN) | Place of manufacture `identification.manufacturing_place` | | `Model version` (also Modellversion, model_version) | Model version `model_version` | | `Battery status` (also Batteriestatus, battery_status) | Battery status `identification.battery_status` | ## Workflows - [Units from production](https://passport.kabin.sh/docs/en/integrations/workflows/units-from-production.md): Serial numbers from the production confirmation or goods receipt are staged as an import; members review, commit and approve before any passport appears. Key scopes: import - [Passport links back into the ERP](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp.md): The middleware follows the change feed and writes the GS1 Digital Link and the passport status to the serial number record in the ERP, so labels and delivery papers carry it. Key scopes: read - [Returns and repairs as lifecycle events](https://passport.kabin.sh/docs/en/integrations/workflows/returns-and-repairs.md): Completed repairs, inspections and component replacements from service or return orders appear as events in the unit's history. Key scopes: read, condition - [Product change leads to a new model version](https://passport.kabin.sh/docs/en/integrations/workflows/product-change.md): A change to the product master in the ERP triggers a notification; a member creates a new model version in BatteryPass. Declared data is never written automatically. Key scopes: read --- # API reference > Every operation of the published contract, with a playground and code samples. The reference is generated from the published contract of the integration API (OpenAPI 3.0) when these pages are built. It shows exactly the operations an API key or a public call can use; the portal's own operations are not part of it (see [Stability and versioning](https://passport.kabin.sh/docs/en/versioning)). ## Basics | | | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | Base URL | `https:///api/v1` (see [Environments](https://passport.kabin.sh/docs/en/environments)) | | Authentication | `Authorization: Bearer bp_live_…` with scope `read`, `condition` or `import` ([Authentication](https://passport.kabin.sh/docs/en/authentication)) | | Format | JSON, times in UTC, ids as UUIDs | | Errors | RFC 9457 Problem Details with a stable `code` ([Errors](https://passport.kabin.sh/docs/en/errors)) | | Lists | Keyset pagination with `limit` and `cursor` ([Pagination](https://passport.kabin.sh/docs/en/pagination)) | | Limits | `429 rate_limited` with `Retry-After` ([Rate limits](https://passport.kabin.sh/docs/en/rate-limits)) | > [!TIP] > **Playground** > > Every operation page has a playground ("Try in Playground"). It sends the request from your browser to the > BatteryPass instance these docs run on. Your key stays in this tab only (sessionStorage) and is sent without > cookies; use a key with scope `read` for it. ## Download the contract - [`openapi.json`](https://passport.kabin.sh/docs/openapi.json) and [`openapi.yaml`](https://passport.kabin.sh/docs/openapi.yaml): the version these docs were built from. - `/api/v1/openapi.json` and `/api/v1/openapi.yaml` on your BatteryPass address: the version your instance serves right now. ## All operations ### Catalog [Units (individual batteries) of the active organisation](https://passport.kabin.sh/docs/en/api-reference/catalog/list-batteries.md): `GET /batteries` (read) [The units as a spreadsheet with the import's columns](https://passport.kabin.sh/docs/en/api-reference/catalog/export-units.md): `GET /batteries/export` (read) [A unit](https://passport.kabin.sh/docs/en/api-reference/catalog/get-battery.md): `GET /batteries/{batteryId}` (read) [List the battery models of the active organisation](https://passport.kabin.sh/docs/en/api-reference/catalog/list-battery-models.md): `GET /battery-models` (read) [A battery model](https://passport.kabin.sh/docs/en/api-reference/catalog/get-battery-model.md): `GET /battery-models/{modelId}` (read) [Versions of a battery model, newest effective date first](https://passport.kabin.sh/docs/en/api-reference/catalog/list-model-versions.md): `GET /battery-models/{modelId}/versions` (read) [A model version](https://passport.kabin.sh/docs/en/api-reference/catalog/get-model-version.md): `GET /model-versions/{versionId}` (read) ### Values [Registry evaluation of the unit-level fields](https://passport.kabin.sh/docs/en/api-reference/values/get-battery-completeness.md): `GET /batteries/{batteryId}/completeness` (read) [Current unit-level values of a unit](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-values.md): `GET /batteries/{batteryId}/values` (read) [All revisions of one unit-level value, newest first](https://passport.kabin.sh/docs/en/api-reference/values/list-battery-value-history.md): `GET /batteries/{batteryId}/values/{fieldKey}/history` (read) [Registry evaluation of the model-level fields](https://passport.kabin.sh/docs/en/api-reference/values/get-model-version-completeness.md): `GET /model-versions/{versionId}/completeness` (read) [Current model-level values of a version](https://passport.kabin.sh/docs/en/api-reference/values/list-model-version-values.md): `GET /model-versions/{versionId}/values` (read) [All revisions of one model-level value, newest first](https://passport.kabin.sh/docs/en/api-reference/values/list-model-version-value-history.md): `GET /model-versions/{versionId}/values/{fieldKey}/history` (read) ### Evidence [The evidence library, newest first](https://passport.kabin.sh/docs/en/api-reference/evidence/list-evidence.md): `GET /evidence` (read) [One document with its revisions and where it is used](https://passport.kabin.sh/docs/en/api-reference/evidence/get-evidence.md): `GET /evidence/{evidenceId}` (read) [Download the file](https://passport.kabin.sh/docs/en/api-reference/evidence/download-evidence.md): `GET /evidence/{evidenceId}/download` (read) ### Passports [The passport of a unit](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport.md): `GET /batteries/{batteryId}/passport` (read) [The QR code of the unit's GS1 Digital Link](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr.md): `GET /batteries/{batteryId}/passport/qr` (read) [The passport's registration with the EU registry](https://passport.kabin.sh/docs/en/api-reference/passports/get-registry-submission-status.md): `GET /batteries/{batteryId}/passport/registration` (read) [The published versions of a unit's passport, newest first](https://passport.kabin.sh/docs/en/api-reference/passports/list-passport-versions.md): `GET /batteries/{batteryId}/passport/versions` (read) [One published version with its signed snapshot and validation report](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version.md): `GET /batteries/{batteryId}/passport/versions/{versionNo}` (read) [The BatteryPass v2.0 export of a version (all tiers)](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-json.md): `GET /batteries/{batteryId}/passport/versions/{versionNo}/batterypass-v2` (read) [What changed from an earlier version to this one](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version-diff.md): `GET /batteries/{batteryId}/passport/versions/{versionNo}/diff` (read) [A PDF of a version with every tier, restricted rows tagged, the signature and the QR code](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-pdf.md): `GET /batteries/{batteryId}/passport/versions/{versionNo}/pdf` (read) ### Lifecycle [The condition data of a unit (the registry's dynamic fields)](https://passport.kabin.sh/docs/en/api-reference/lifecycle/get-condition.md): `GET /batteries/{batteryId}/condition` (read) [Report measured condition data (merge)](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition.md): `PATCH /batteries/{batteryId}/condition` (condition) [The lifecycle of a unit, newest first](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events.md): `GET /batteries/{batteryId}/lifecycle-events` (read) [Record an event in the life of a unit](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event.md): `POST /batteries/{batteryId}/lifecycle-events` (condition) ### Registry [Version, content hash and status of the field registry in use](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-info.md): `GET /registry` (read) [The editor schema of a battery category](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-schema.md): `GET /registry/schema` (read) [The registry in use and how many of the organisation's passports need an update](https://passport.kabin.sh/docs/en/api-reference/registry/get-registry-status.md): `GET /registry/status` (read) ### Facilities [Manufacturing facilities of the active organisation](https://passport.kabin.sh/docs/en/api-reference/facilities/list-facilities.md): `GET /facilities` (read) ### Public [The public passport of a unit, looked up by GTIN and serial number](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id.md): `GET /public/dpps/by-product-id/{productId}` (public) [The public passport (current version)](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport.md): `GET /public/passports/{passportId}` (public) [A public document of a public passport](https://passport.kabin.sh/docs/en/api-reference/public/download-public-passport-document.md): `GET /public/passports/{passportId}/documents/{documentId}` (public) [Check the signatures of a version on the server](https://passport.kabin.sh/docs/en/api-reference/public/verify-passport.md): `GET /public/passports/{passportId}/verify` (public) [The public passport as it was at a point in time](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-version.md): `GET /public/passports/{passportId}/version` (public) ### Changes [The organisation's changes, oldest first, for polling](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes.md): `GET /changes` (read) ### Imports [Imports of the active organisation, newest first](https://passport.kabin.sh/docs/en/api-reference/imports/list-imports.md): `GET /imports` (import) [Upload a spreadsheet of units](https://passport.kabin.sh/docs/en/api-reference/imports/create-import.md): `POST /imports` (import) [One import with its mapping and summary](https://passport.kabin.sh/docs/en/api-reference/imports/get-import.md): `GET /imports/{importId}` (import) [Map the columns of an import](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping.md): `PUT /imports/{importId}/mapping` (import) [Start validating every row of an import](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import.md): `POST /imports/{importId}/validate` (import) --- # The units as a spreadsheet with the import's columns > `GET /batteries/export` · Scope: `read` · operationId `exportUnits` Any member. The units matching the filters (at most 10 000) with the columns of an import: model, version, the unit's fields and every unit-level field with a value, so the file can be edited and imported again (`docs/architecture.md` §7). XLSX by default. ## Query parameters - **battery_model_id** `string (uuid)` - **model_version_id** `string (uuid)` - **passport_status** `string` (one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **serial_prefix** `string` - **import_id** `string (uuid)` - **format** `string` (one of: `xlsx`, `csv`): The file format of a spreadsheet download. ## Responses ### 200 (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `text/csv`) The spreadsheet. `any` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/export" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # A battery model > `GET /battery-models/{modelId}` · Scope: `read` · operationId `getBatteryModel` Any member. ## Path parameters - **modelId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The battery model. - **archived_at** `string | null` (required) - **category** `string` (required, one of: `ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`): Battery category per Regulation (EU) 2023/1542; drives registry requirements. - **created_at** `string (date-time)` (required) - **gtin** `string | null` (required): GTIN-14 (normalised, check digit valid). - **id** `string (uuid)` (required) - **name** `string` (required) - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **sku** `string | null` (required) - **updated_at** `string (date-time)` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/battery-models/{modelId}" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # A unit > `GET /batteries/{batteryId}` · Scope: `read` · operationId `getBattery` Any member. ## Path parameters - **batteryId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The unit. - **battery_model_id** `string (uuid)` (required) - **battery_status** `string` (required, one of: `original`, `repurposed`, `reused`, `remanufactured`, `waste`) - **created_at** `string (date-time)` (required) - **facility_id** `string (uuid)` - **id** `string (uuid)` (required) - **identifiers_locked_at** `string (date-time)`: Set when the identifiers were locked (pre-print) or first published; serial and model are fixed afterwards. - **import_id** `string (uuid)`: The import that created the unit. - **manufacturing_date** `string (date)` - **model_version_id** `string (uuid)` (required) - **passport_status** `string` (required, one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **predecessor_battery_id** `string (uuid)`: Second life: the unit this battery was repurposed, reused or remanufactured from. - **putting_into_service** `string (date)` - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **serial_number** `string` (required) - **successor_battery_ids** `string (uuid)[]`: Second life: the units made from this battery. - **updated_at** `string (date-time)` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # A model version > `GET /model-versions/{versionId}` · Scope: `read` · operationId `getModelVersion` Any member. ## Path parameters - **versionId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The version. - **approved_at** `string (date-time)` - **approved_by_name** `string` - **battery_model_id** `string (uuid)` (required) - **created_at** `string (date-time)` (required) - **effective_from** `string (date)` (required) - **id** `string (uuid)` (required) - **registry_version** `string`: Registry version of the last evaluation after a change. - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **status** `string` (required, one of: `draft`, `approved`, `superseded`) - **unit_count** `integer` (required) - **updated_at** `string (date-time)` (required) - **version_label** `string` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/model-versions/{versionId}" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Units (individual batteries) of the active organisation > `GET /batteries` · Scope: `read` · operationId `listBatteries` Any member. Ordered by serial number, then id; filter by model, version, passport status, serial number prefix or the import that created the units. ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. - **battery_model_id** `string (uuid)` - **model_version_id** `string (uuid)` - **passport_status** `string` (one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **serial_prefix** `string` - **import_id** `string (uuid)` ## Responses ### 200 (`application/json`) One page of units. - **items** `Battery[]` (required) - **battery_model_id** `string (uuid)` (required) - **battery_status** `string` (required, one of: `original`, `repurposed`, `reused`, `remanufactured`, `waste`) - **created_at** `string (date-time)` (required) - **facility_id** `string (uuid)` - **id** `string (uuid)` (required) - **identifiers_locked_at** `string (date-time)`: Set when the identifiers were locked (pre-print) or first published; serial and model are fixed afterwards. - **import_id** `string (uuid)`: The import that created the unit. - **manufacturing_date** `string (date)` - **model_version_id** `string (uuid)` (required) - **passport_status** `string` (required, one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **predecessor_battery_id** `string (uuid)`: Second life: the unit this battery was repurposed, reused or remanufactured from. - **putting_into_service** `string (date)` - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **serial_number** `string` (required) - **successor_battery_ids** `string (uuid)[]`: Second life: the units made from this battery. - **updated_at** `string (date-time)` (required) - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # List the battery models of the active organisation > `GET /battery-models` · Scope: `read` · operationId `listBatteryModels` Any member. Ordered by name, then id. Archived models only with `include_archived=true`. ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. - **include_archived** `boolean` (default: false) ## Responses ### 200 (`application/json`) One page of battery models. - **items** `BatteryModel[]` (required) - **archived_at** `string | null` (required) - **category** `string` (required, one of: `ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`): Battery category per Regulation (EU) 2023/1542; drives registry requirements. - **created_at** `string (date-time)` (required) - **gtin** `string | null` (required): GTIN-14 (normalised, check digit valid). - **id** `string (uuid)` (required) - **name** `string` (required) - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **sku** `string | null` (required) - **updated_at** `string (date-time)` (required) - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/battery-models" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Versions of a battery model, newest effective date first > `GET /battery-models/{modelId}/versions` · Scope: `read` · operationId `listModelVersions` Any member. At most 200. ## Path parameters - **modelId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The versions. - **items** `ModelVersion[]` (required) - **approved_at** `string (date-time)` - **approved_by_name** `string` - **battery_model_id** `string (uuid)` (required) - **created_at** `string (date-time)` (required) - **effective_from** `string (date)` (required) - **id** `string (uuid)` (required) - **registry_version** `string`: Registry version of the last evaluation after a change. - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **status** `string` (required, one of: `draft`, `approved`, `superseded`) - **unit_count** `integer` (required) - **updated_at** `string (date-time)` (required) - **version_label** `string` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/battery-models/{modelId}/versions" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Registry evaluation of the unit-level fields > `GET /batteries/{batteryId}/completeness` · Scope: `read` · operationId `getBatteryCompleteness` Any member. Evaluated against the category, the unit's putting-into-service date (else its manufacturing date, else the version's effective date) and the organisation's facts; includes the summary of its model version. ## Path parameters - **batteryId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The evaluation. - **category** `string` (required, one of: `ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`): Battery category per Regulation (EU) 2023/1542; drives registry requirements. - **fields** `FieldCompleteness[]` (required): Visible fields in registry order (hidden fields are left out). - **applies_from** `string (date)` - **display** `string` (required, one of: `required_missing`, `required_pending_review`, `ok`, `not_applicable`, `later_required`, `optional_missing`): Combined status shown to users (docs/domain-model.md §5). - **field_key** `string` (required) - **not_applicable_reason** `string` - **reason** `string` (required, one of: `rule`, `condition_met`, `condition_open`, `condition_not_met`, `applies_from`, `trigger`, `marked_not_applicable`): Why the field has its requirement: `rule` (registry rule for the category), `condition_met`, `condition_open` (cannot be decided yet; treated as required), `condition_not_met`, `applies_from` (obligation starts after the reference date), `trigger` (a legal act is still pending), `marked_not_applicable` (a user recorded a reason). - **requirement** `string` (required, one of: `required`, `conditional`, `optional`, `not_applicable`, `later_required`) - **section** `string` (required) - **trigger** `LabelText`: Text in German and English (ADR-010); clients pick the locale. - **value_status** `string` (required, one of: `missing`, `captured`, `auto_checked`, `approved`, `rejected`) - **model_version** `CompletenessSummary` - **blocking** `integer` (required): Fields that count against publishing (required_missing + required_pending_review). - **counts** `StatusCounts` (required) - **total** `integer` (required) - **reference_date** `string (date)`: The placed-on-market date obligations were evaluated for. - **registry** `RegistryRef` (required) - **hash** `string` (required) - **status** `string` (required, one of: `seed`, `verified`): `seed` until verified against primary sources (docs/registry/verification-log.md). - **version** `string` (required) - **summary** `CompletenessSummary` (required) - **blocking** `integer` (required): Fields that count against publishing (required_missing + required_pending_review). - **counts** `StatusCounts` (required) - **total** `integer` (required) - **target** `TargetRef` (required) - **id** `string (uuid)` (required) - **type** `string` (required, one of: `model_version`, `battery`) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/completeness" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Registry evaluation of the model-level fields > `GET /model-versions/{versionId}/completeness` · Scope: `read` · operationId `getModelVersionCompleteness` Any member. Evaluated against the category, the version's effective date and the organisation's facts. ## Path parameters - **versionId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The evaluation. - **category** `string` (required, one of: `ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`): Battery category per Regulation (EU) 2023/1542; drives registry requirements. - **fields** `FieldCompleteness[]` (required): Visible fields in registry order (hidden fields are left out). - **applies_from** `string (date)` - **display** `string` (required, one of: `required_missing`, `required_pending_review`, `ok`, `not_applicable`, `later_required`, `optional_missing`): Combined status shown to users (docs/domain-model.md §5). - **field_key** `string` (required) - **not_applicable_reason** `string` - **reason** `string` (required, one of: `rule`, `condition_met`, `condition_open`, `condition_not_met`, `applies_from`, `trigger`, `marked_not_applicable`): Why the field has its requirement: `rule` (registry rule for the category), `condition_met`, `condition_open` (cannot be decided yet; treated as required), `condition_not_met`, `applies_from` (obligation starts after the reference date), `trigger` (a legal act is still pending), `marked_not_applicable` (a user recorded a reason). - **requirement** `string` (required, one of: `required`, `conditional`, `optional`, `not_applicable`, `later_required`) - **section** `string` (required) - **trigger** `LabelText`: Text in German and English (ADR-010); clients pick the locale. - **value_status** `string` (required, one of: `missing`, `captured`, `auto_checked`, `approved`, `rejected`) - **model_version** `CompletenessSummary` - **blocking** `integer` (required): Fields that count against publishing (required_missing + required_pending_review). - **counts** `StatusCounts` (required) - **total** `integer` (required) - **reference_date** `string (date)`: The placed-on-market date obligations were evaluated for. - **registry** `RegistryRef` (required) - **hash** `string` (required) - **status** `string` (required, one of: `seed`, `verified`): `seed` until verified against primary sources (docs/registry/verification-log.md). - **version** `string` (required) - **summary** `CompletenessSummary` (required) - **blocking** `integer` (required): Fields that count against publishing (required_missing + required_pending_review). - **counts** `StatusCounts` (required) - **total** `integer` (required) - **target** `TargetRef` (required) - **id** `string (uuid)` (required) - **type** `string` (required, one of: `model_version`, `battery`) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/model-versions/{versionId}/completeness" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # All revisions of one unit-level value, newest first > `GET /batteries/{batteryId}/values/{fieldKey}/history` · Scope: `read` · operationId `listBatteryValueHistory` Any member. ## Path parameters - **batteryId** `string (uuid)` (required) - **fieldKey** `string` (required): Registry field key, e.g. `performance.nominal_voltage`. ## Responses ### 200 (`application/json`) The revisions. - **items** `FieldValue[]` (required) - **checks** `ValueCheck[]` (required) - **evidence** `EvidenceRef[]`: Documents this revision rests on (set on approved revisions and supplier submissions). - **field_key** `string` (required) - **measured_at** `string (date-time)`: When a measured value (condition data, ADR-016) was measured. - **recorded_at** `string (date-time)` (required) - **recorded_by** `UserRef` - **registry_version** `string` (required) - **responsible** `UserRef` - **review_note** `string` - **review_status** `string` (required, one of: `captured`, `auto_checked`, `approved`, `rejected`) - **reviewed_at** `string (date-time)` - **reviewed_by** `UserRef` - **revision** `integer` (required) - **source** `string` (required, one of: `manual`, `import`, `supplier`, `api`, `system`) - **superseded_at** `string (date-time)` - **unit** `string` - **value** `any | null` (required): The value as JSON; null when the field was cleared. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/values/{fieldKey}/history" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Current unit-level values of a unit > `GET /batteries/{batteryId}/values` · Scope: `read` · operationId `listBatteryValues` Any member. One entry per field that has (or had) a value; `value` is null when it was cleared. ## Path parameters - **batteryId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The current values. - **items** `FieldValue[]` (required) - **checks** `ValueCheck[]` (required) - **evidence** `EvidenceRef[]`: Documents this revision rests on (set on approved revisions and supplier submissions). - **field_key** `string` (required) - **measured_at** `string (date-time)`: When a measured value (condition data, ADR-016) was measured. - **recorded_at** `string (date-time)` (required) - **recorded_by** `UserRef` - **registry_version** `string` (required) - **responsible** `UserRef` - **review_note** `string` - **review_status** `string` (required, one of: `captured`, `auto_checked`, `approved`, `rejected`) - **reviewed_at** `string (date-time)` - **reviewed_by** `UserRef` - **revision** `integer` (required) - **source** `string` (required, one of: `manual`, `import`, `supplier`, `api`, `system`) - **superseded_at** `string (date-time)` - **unit** `string` - **value** `any | null` (required): The value as JSON; null when the field was cleared. - **links** `EvidenceLink[]` (required): Active evidence links of the target, per field. - **declared_value** `any | null`: The value the document states for the field, if recorded. - **evidence** `EvidenceRef` (required) - **field_key** `string` (required) - **id** `string (uuid)` (required) - **linked_at** `string (date-time)` (required) - **linked_by** `UserRef` - **registry** `RegistryRef` (required) - **hash** `string` (required) - **status** `string` (required, one of: `seed`, `verified`): `seed` until verified against primary sources (docs/registry/verification-log.md). - **version** `string` (required) - **target** `TargetRef` (required) - **id** `string (uuid)` (required) - **type** `string` (required, one of: `model_version`, `battery`) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/values" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # All revisions of one model-level value, newest first > `GET /model-versions/{versionId}/values/{fieldKey}/history` · Scope: `read` · operationId `listModelVersionValueHistory` Any member. ## Path parameters - **versionId** `string (uuid)` (required) - **fieldKey** `string` (required): Registry field key, e.g. `performance.nominal_voltage`. ## Responses ### 200 (`application/json`) The revisions. - **items** `FieldValue[]` (required) - **checks** `ValueCheck[]` (required) - **evidence** `EvidenceRef[]`: Documents this revision rests on (set on approved revisions and supplier submissions). - **field_key** `string` (required) - **measured_at** `string (date-time)`: When a measured value (condition data, ADR-016) was measured. - **recorded_at** `string (date-time)` (required) - **recorded_by** `UserRef` - **registry_version** `string` (required) - **responsible** `UserRef` - **review_note** `string` - **review_status** `string` (required, one of: `captured`, `auto_checked`, `approved`, `rejected`) - **reviewed_at** `string (date-time)` - **reviewed_by** `UserRef` - **revision** `integer` (required) - **source** `string` (required, one of: `manual`, `import`, `supplier`, `api`, `system`) - **superseded_at** `string (date-time)` - **unit** `string` - **value** `any | null` (required): The value as JSON; null when the field was cleared. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/model-versions/{versionId}/values/{fieldKey}/history" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Current model-level values of a version > `GET /model-versions/{versionId}/values` · Scope: `read` · operationId `listModelVersionValues` Any member. One entry per field that has (or had) a value; `value` is null when it was cleared. ## Path parameters - **versionId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The current values. - **items** `FieldValue[]` (required) - **checks** `ValueCheck[]` (required) - **evidence** `EvidenceRef[]`: Documents this revision rests on (set on approved revisions and supplier submissions). - **field_key** `string` (required) - **measured_at** `string (date-time)`: When a measured value (condition data, ADR-016) was measured. - **recorded_at** `string (date-time)` (required) - **recorded_by** `UserRef` - **registry_version** `string` (required) - **responsible** `UserRef` - **review_note** `string` - **review_status** `string` (required, one of: `captured`, `auto_checked`, `approved`, `rejected`) - **reviewed_at** `string (date-time)` - **reviewed_by** `UserRef` - **revision** `integer` (required) - **source** `string` (required, one of: `manual`, `import`, `supplier`, `api`, `system`) - **superseded_at** `string (date-time)` - **unit** `string` - **value** `any | null` (required): The value as JSON; null when the field was cleared. - **links** `EvidenceLink[]` (required): Active evidence links of the target, per field. - **declared_value** `any | null`: The value the document states for the field, if recorded. - **evidence** `EvidenceRef` (required) - **field_key** `string` (required) - **id** `string (uuid)` (required) - **linked_at** `string (date-time)` (required) - **linked_by** `UserRef` - **registry** `RegistryRef` (required) - **hash** `string` (required) - **status** `string` (required, one of: `seed`, `verified`): `seed` until verified against primary sources (docs/registry/verification-log.md). - **version** `string` (required) - **target** `TargetRef` (required) - **id** `string (uuid)` (required) - **type** `string` (required, one of: `model_version`, `battery`) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/model-versions/{versionId}/values" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Download the file > `GET /evidence/{evidenceId}/download` · Scope: `read` · operationId `downloadEvidence` Documents of the tiers `public` and `legitimate_interest` can be downloaded by any member; `authority` and `internal` documents by editors and above. Always sent as an attachment. ## Path parameters - **evidenceId** `string (uuid)` (required) ## Responses ### 200 (`*/*`) The file. `any` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/evidence/{evidenceId}/download" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # One document with its revisions and where it is used > `GET /evidence/{evidenceId}` · Scope: `read` · operationId `getEvidence` Any member. ## Path parameters - **evidenceId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The document. - **access_tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`, `internal`): Access tier of a field on the passport (Art. 77(2), Annex XIII). - **created_at** `string (date-time)` (required) - **filename** `string` (required) - **id** `string (uuid)` (required) - **kind** `string` (required, one of: `declaration_of_conformity`, `test_report`, `dismantling_manual`, `safety_instructions`, `datasheet`, `diagnostic_report`, `study`, `image`, `other`) - **mime** `string` (required) - **revision** `integer` (required) - **revisions** `EvidenceRef[]`: All revisions of the document, newest first (getEvidence only). - **access_tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`, `internal`): Access tier of a field on the passport (Art. 77(2), Annex XIII). - **filename** `string` (required) - **id** `string (uuid)` (required) - **kind** `string` (required, one of: `declaration_of_conformity`, `test_report`, `dismantling_manual`, `safety_instructions`, `datasheet`, `diagnostic_report`, `study`, `image`, `other`) - **revision** `integer` (required) - **superseded** `boolean` (required) - **title** `string` (required) - **sha256** `string` (required) - **size_bytes** `integer (int64)` (required) - **source** `string` (required, one of: `manual`, `supplier`) - **superseded** `EvidenceSupersession` - **at** `string (date-time)` (required) - **by** `UserRef` - **reason** `string` (required) - **replaced_by** `string (uuid)` - **supersedes** `string (uuid)`: The previous revision of this document. - **supplier_request_id** `string (uuid)` - **title** `string` (required) - **uploaded_by** `UserRef` - **display_name** `string` (required) - **id** `string (uuid)` (required) - **usages** `EvidenceUsage[]`: Where the document is linked or used as a value (getEvidence only). - **declared_value** `any | null`: The value the document states (links only). - **field_key** `string` (required) - **target** `TargetRef` (required) - **target_label** `string` (required): Model name and version label, or the unit's serial number. - **via** `string` (required, one of: `link`, `value`): `link`: an evidence link; `value`: the current value of a `file` field is this document. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/evidence/{evidenceId}" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The evidence library, newest first > `GET /evidence` · Scope: `read` · operationId `listEvidence` Any member. Superseded revisions are left out unless `include_superseded=true`. ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. - **kind** `string` (one of: `declaration_of_conformity`, `test_report`, `dismantling_manual`, `safety_instructions`, `datasheet`, `diagnostic_report`, `study`, `image`, `other`) - **include_superseded** `boolean` (default: false) ## Responses ### 200 (`application/json`) A page of documents. - **items** `Evidence[]` (required) - **access_tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`, `internal`): Access tier of a field on the passport (Art. 77(2), Annex XIII). - **created_at** `string (date-time)` (required) - **filename** `string` (required) - **id** `string (uuid)` (required) - **kind** `string` (required, one of: `declaration_of_conformity`, `test_report`, `dismantling_manual`, `safety_instructions`, `datasheet`, `diagnostic_report`, `study`, `image`, `other`) - **mime** `string` (required) - **revision** `integer` (required) - **revisions** `EvidenceRef[]`: All revisions of the document, newest first (getEvidence only). - **sha256** `string` (required) - **size_bytes** `integer (int64)` (required) - **source** `string` (required, one of: `manual`, `supplier`) - **superseded** `EvidenceSupersession` - **supersedes** `string (uuid)`: The previous revision of this document. - **supplier_request_id** `string (uuid)` - **title** `string` (required) - **uploaded_by** `UserRef` - **usages** `EvidenceUsage[]`: Where the document is linked or used as a value (getEvidence only). - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/evidence" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The BatteryPass v2.0 export of a version (all tiers) > `GET /batteries/{batteryId}/passport/versions/{versionNo}/batterypass-v2` · Scope: `read` · operationId `exportPassportJson` Any member. The stored export, including restricted data; sent as an attachment. Its schema report is in `getPassportVersion` (advisory, ADR-012). ## Path parameters - **batteryId** `string (uuid)` (required) - **versionNo** `integer` (required) ## Responses ### 200 (`application/json`) The export. `JSONDocument` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport/versions/{versionNo}/batterypass-v2" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # A PDF of a version with every tier, restricted rows tagged, the signature and the QR code > `GET /batteries/{batteryId}/passport/versions/{versionNo}/pdf` · Scope: `read` · operationId `exportPassportPdf` Any member. ## Path parameters - **batteryId** `string (uuid)` (required) - **versionNo** `integer` (required) ## Query parameters - **lang** `string` (one of: `de`, `en`): Preferred language for emails and the portal. ## Responses ### 200 (`application/pdf`) The PDF. `any` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport/versions/{versionNo}/pdf" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The QR code of the unit's GS1 Digital Link > `GET /batteries/{batteryId}/passport/qr` · Scope: `read` · operationId `getPassportQr` Any member. Rendered on demand from `gs1_digital_link` (error correction M, quiet zone of four modules). Only once the identifiers are locked (`409 conflict` before). ## Path parameters - **batteryId** `string (uuid)` (required) ## Query parameters - **format** `string` (default: "png", one of: `png`, `svg`) - **size** `integer` (default: 512): Width and height of the PNG in pixels (ignored for SVG, which scales). ## Responses ### 200 (`image/png`, `image/svg+xml`) The image. `any` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 409 (`application/problem+json`) The request conflicts with the current state (`conflict`), e.g. a stale `If-Match` version. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport/qr" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # What changed from an earlier version to this one > `GET /batteries/{batteryId}/passport/versions/{versionNo}/diff` · Scope: `read` · operationId `getPassportVersionDiff` Any member. Compares the signed snapshots field by field; `against` defaults to the previous version. ## Path parameters - **batteryId** `string (uuid)` (required) - **versionNo** `integer` (required) ## Query parameters - **against** `integer` ## Responses ### 200 (`application/json`) The changes. - **fields** `PassportFieldChange[]` (required) - **after** `PassportDiffValue` - **before** `PassportDiffValue` - **change** `string` (required, one of: `added`, `removed`, `changed`, `provenance`): `provenance`: same value, a newer revision (re-approved, re-measured). - **key** `string` (required) - **level** `string` (required, one of: `model`, `unit`) - **from_version_no** `integer`: Absent when there is nothing to compare with (the first publication). - **metadata** `PassportMetaChange[]` (required) - **after** `string` - **before** `string` - **key** `string` (required, one of: `model_version`, `registry_version`, `category`, `facility`, `operator`, `predecessor`) - **to** `string` (required, one of: `version`, `preview`) - **to_version_no** `integer`: Absent in a publish preview. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport/versions/{versionNo}/diff" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # One published version with its signed snapshot and validation report > `GET /batteries/{batteryId}/passport/versions/{versionNo}` · Scope: `read` · operationId `getPassportVersion` Any member. ## Path parameters - **batteryId** `string (uuid)` (required) - **versionNo** `integer` (required) ## Responses ### 200 (`application/json`) The version. - **batterypass_v2_issue_count** `integer` (required) - **batterypass_v2_valid** `boolean` (required): Whether the BatteryPass v2.0 export passed its schema (advisory, ADR-012). - **canonical_hash** `string` (required): `sha256:` of the JCS-canonical snapshot (ADR-004). - **key_id** `string` (required) - **public_hash** `string` (required): `sha256:` of the JCS-canonical public document (ADR-013). - **published_at** `string (date-time)` (required) - **published_by** `string (uuid)` - **published_by_name** `string` - **reason** `string` (required) - **registry_version** `string` (required) - **valid_until** `string (date-time)`: When a later version replaced this one. - **version_no** `integer` (required) - **batterypass_v2** `SchemaReport` (required): Result of validating a BatteryPass v2.0 export against its schema (advisory, ADR-012). - **issues** `SchemaIssue[]` (required) - **schema** `string` (required): The schema file, e.g. `Stationary_Industrial_Above_2kWh_Feb27_2.0.1.json`. - **valid** `boolean` (required) - **public_document** `PassportDocument` (required): A passport version projected to an access tier. The `public` projection is stored and signed at publication (ADR-013); verify it by canonicalising this object with RFC 8785 (JCS), hashing it with SHA-256 and checking the Ed25519 signature. - **category** `PassportCategory` (required) - **economic_operator** `PublicProfile`: The economic operator as shown on public passports. Every property is optional; publishing (M4) requires a complete profile. - **gs1_digital_link** `string` (required) - **gtin** `string` (required) - **model_name** `string` (required): The name of the battery model (the product name), for headings. - **passport_id** `string`: Absent only in a preview of a passport that has no identifier yet. - **predecessor_passport_id** `string`: Second life: the passport identifier of the battery this one was repurposed, reused or remanufactured from. - **restricted_fields_present** `string[]` (required): Keys of fields with a value above this tier. Keys only, never values. - **sections** `PassportSection[]` (required) - **serial_number** `string` (required) - **tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`): The access tier a passport document is projected to; each includes the tiers before it. - **version** `PassportDocumentVersion` (required) - **public_signature** `string` (required): Ed25519 signature of the canonical public document bytes (base64url, no padding). - **registry_hash** `string` (required) - **signature** `string` (required): Ed25519 signature of the canonical snapshot bytes (base64url, no padding). - **snapshot** `JSONDocument` (required): A JSON document whose shape is described elsewhere (snapshot, BatteryPass v2.0 export). ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport/versions/{versionNo}" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The passport of a unit > `GET /batteries/{batteryId}/passport` · Scope: `read` · operationId `getPassport` Any member. A unit whose identifiers are not locked and that was never published has a passport in status `draft` without `passport_id`. ## Path parameters - **batteryId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The passport. - **battery_id** `string (uuid)` (required) - **current_version** `PassportVersionSummary` - **batterypass_v2_issue_count** `integer` (required) - **batterypass_v2_valid** `boolean` (required): Whether the BatteryPass v2.0 export passed its schema (advisory, ADR-012). - **canonical_hash** `string` (required): `sha256:` of the JCS-canonical snapshot (ADR-004). - **key_id** `string` (required) - **public_hash** `string` (required): `sha256:` of the JCS-canonical public document (ADR-013). - **published_at** `string (date-time)` (required) - **published_by** `string (uuid)` - **published_by_name** `string` - **reason** `string` (required) - **registry_version** `string` (required) - **valid_until** `string (date-time)`: When a later version replaced this one. - **version_no** `integer` (required) - **first_published_at** `string (date-time)` - **gs1_digital_link** `string`: `{RESOLVER_BASE_URL}/01/{gtin}/21/{serial}`; fixed once the identifiers are locked, a preview before. - **gtin** `string`: The model's GTIN-14. - **identifiers_locked_at** `string (date-time)` - **passport_id** `string`: Permanent passport identifier `urn:dpp:{uuid}`, assigned when the identifiers are locked or at the first publication. - **public_url** `string`: The public passport page, once published. - **row_version** `integer (int64)`: Optimistic-concurrency version; send it back as `If-Match` when updating. - **serial_number** `string` (required) - **status** `string` (required, one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **withdrawn_at** `string (date-time)` - **withdrawn_reason** `string` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The passport's registration with the EU registry > `GET /batteries/{batteryId}/passport/registration` · Scope: `read` · operationId `getRegistrySubmissionStatus` Any member. The EU digital product passport registry has no interface for economic operators yet (`docs/regulatory-reference.md`), so the registration is `not_available` and nothing is submitted. A passport is never shown as registered without the registry's confirmation. ## Path parameters - **batteryId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The registration status. - **attempts** `integer` - **available** `boolean` (required): Whether the platform can submit to the registry at all. - **detail** `string`: Why the registration is not available or failed (English). - **external_id** `string` - **last_attempt_at** `string (date-time)` - **passport_version** `integer`: The version the latest submission concerns. - **registry** `string` (required, one of: `eu_dpp`) - **state** `string` (required, one of: `not_available`, `not_submitted`, `pending`, `submitted`, `confirmed`, `failed`): `not_available`: the registry has no interface yet, nothing is submitted. The other states belong to a registry adapter that does not exist yet: `not_submitted` (available, nothing sent for this passport), `pending` (queued), `submitted` (sent, no answer), `confirmed` (the registry confirmed receipt), `failed`. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport/registration" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The published versions of a unit's passport, newest first > `GET /batteries/{batteryId}/passport/versions` · Scope: `read` · operationId `listPassportVersions` Any member. With `at`, only versions published at or before that instant: the first item is the version that was valid then (history by date for the portal, with every tier). ## Path parameters - **batteryId** `string (uuid)` (required) ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. - **at** `string (date-time)` ## Responses ### 200 (`application/json`) A page of versions. - **items** `PassportVersionSummary[]` (required) - **batterypass_v2_issue_count** `integer` (required) - **batterypass_v2_valid** `boolean` (required): Whether the BatteryPass v2.0 export passed its schema (advisory, ADR-012). - **canonical_hash** `string` (required): `sha256:` of the JCS-canonical snapshot (ADR-004). - **key_id** `string` (required) - **public_hash** `string` (required): `sha256:` of the JCS-canonical public document (ADR-013). - **published_at** `string (date-time)` (required) - **published_by** `string (uuid)` - **published_by_name** `string` - **reason** `string` (required) - **registry_version** `string` (required) - **valid_until** `string (date-time)`: When a later version replaced this one. - **version_no** `integer` (required) - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/passport/versions" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Record an event in the life of a unit > `POST /batteries/{batteryId}/lifecycle-events` · Scope: `condition` · operationId `createLifecycleEvent` Editor and above, or an API key with the `condition` scope (source `api`, ADR-017). `occurred_on` lies between the manufacturing date and today. `evidence_ids` name documents of the evidence library (a diagnostic report, a repair protocol). Events are records: they do not change the unit's data; condition values are reported with `updateCondition`, second life with `createSecondLifeBattery`. ## Path parameters - **batteryId** `string (uuid)` (required) ## Request body (`application/json`) - **description** `string` - **evidence_ids** `string (uuid)[]` - **occurred_on** `string (date)` (required) - **type** `string` (required, one of: `placed_on_market`, `put_into_service`, `inspection`, `repair`, `component_replaced`, `condition_update`, `waste`, `recycled`, `custom`): The event types members and service partners report. ## Responses ### 201 (`application/json`) The event. - **api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **id** `string (uuid)` (required) - **name** `string` (required) - **battery_id** `string (uuid)` (required) - **created_at** `string (date-time)` (required) - **description** `string` - **evidence** `EvidenceRef[]` (required) - **access_tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`, `internal`): Access tier of a field on the passport (Art. 77(2), Annex XIII). - **filename** `string` (required) - **id** `string (uuid)` (required) - **kind** `string` (required, one of: `declaration_of_conformity`, `test_report`, `dismantling_manual`, `safety_instructions`, `datasheet`, `diagnostic_report`, `study`, `image`, `other`) - **revision** `integer` (required) - **superseded** `boolean` (required) - **title** `string` (required) - **field_keys** `string[]` (required): The condition fields this event updated. - **id** `string (uuid)` (required) - **occurred_on** `string (date)` (required) - **partner** `ServicePartnerRef` - **email** `string (email)` (required) - **name** `string` - **request_id** `string (uuid)` (required) - **related_battery_id** `string (uuid)`: Second life: the successor (on the predecessor's event) or the predecessor (on the successor's). - **related_serial_number** `string` - **reported_by** `UserRef` - **display_name** `string` (required) - **id** `string (uuid)` (required) - **source** `string` (required, one of: `manual`, `supplier`, `api`, `system`): `manual` (a member), `supplier` (a service-partner link), `api` (an API key, M7), `system`. - **type** `string` (required, one of: `placed_on_market`, `put_into_service`, `inspection`, `repair`, `component_replaced`, `condition_update`, `repurposed`, `reused`, `remanufactured`, `waste`, `recycled`, `custom`): What happened to a unit. `condition_update` is recorded by condition updates, `repurposed`, `reused` and `remanufactured` by `createSecondLifeBattery`; the others are reported. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 409 (`application/problem+json`) The request conflicts with the current state (`conflict`), e.g. a stale `If-Match` version. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X POST "https://passport.kabin.sh/api/v1/batteries/{batteryId}/lifecycle-events" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ -H "Content-Type: application/json" \ -d @body.json ``` --- # The condition data of a unit (the registry's dynamic fields) > `GET /batteries/{batteryId}/condition` · Scope: `read` · operationId `getCondition` Any member. The dynamic fields visible for the unit and their current values with `measured_at`. ## Path parameters - **batteryId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The condition data. - **battery_id** `string (uuid)` (required) - **field_keys** `string[]` (required): The dynamic fields visible for the unit, in registry order. - **republish** `string` (required, one of: `immediate`, `daily`): When accepted condition data reaches a published passport (ADR-016): `immediate` or `daily` (02:00 UTC). - **values** `FieldValue[]` (required): Current values of these fields. - **checks** `ValueCheck[]` (required) - **evidence** `EvidenceRef[]`: Documents this revision rests on (set on approved revisions and supplier submissions). - **field_key** `string` (required) - **measured_at** `string (date-time)`: When a measured value (condition data, ADR-016) was measured. - **recorded_at** `string (date-time)` (required) - **recorded_by** `UserRef` - **registry_version** `string` (required) - **responsible** `UserRef` - **review_note** `string` - **review_status** `string` (required, one of: `captured`, `auto_checked`, `approved`, `rejected`) - **reviewed_at** `string (date-time)` - **reviewed_by** `UserRef` - **revision** `integer` (required) - **source** `string` (required, one of: `manual`, `import`, `supplier`, `api`, `system`) - **superseded_at** `string (date-time)` - **unit** `string` - **value** `any | null` (required): The value as JSON; null when the field was cleared. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/condition" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The lifecycle of a unit, newest first > `GET /batteries/{batteryId}/lifecycle-events` · Scope: `read` · operationId `listLifecycleEvents` Any member. Events recorded by members, service-partner reports, condition updates and second life. ## Path parameters - **batteryId** `string (uuid)` (required) ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. ## Responses ### 200 (`application/json`) A page of events. - **items** `LifecycleEvent[]` (required) - **api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **battery_id** `string (uuid)` (required) - **created_at** `string (date-time)` (required) - **description** `string` - **evidence** `EvidenceRef[]` (required) - **field_keys** `string[]` (required): The condition fields this event updated. - **id** `string (uuid)` (required) - **occurred_on** `string (date)` (required) - **partner** `ServicePartnerRef` - **related_battery_id** `string (uuid)`: Second life: the successor (on the predecessor's event) or the predecessor (on the successor's). - **related_serial_number** `string` - **reported_by** `UserRef` - **source** `string` (required, one of: `manual`, `supplier`, `api`, `system`): `manual` (a member), `supplier` (a service-partner link), `api` (an API key, M7), `system`. - **type** `string` (required, one of: `placed_on_market`, `put_into_service`, `inspection`, `repair`, `component_replaced`, `condition_update`, `repurposed`, `reused`, `remanufactured`, `waste`, `recycled`, `custom`): What happened to a unit. `condition_update` is recorded by condition updates, `repurposed`, `reused` and `remanufactured` by `createSecondLifeBattery`; the others are reported. - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/batteries/{batteryId}/lifecycle-events" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Report measured condition data (merge) > `PATCH /batteries/{batteryId}/condition` · Scope: `condition` · operationId `updateCondition` Editor and above, or an API key with the `condition` scope (source `api`, ADR-016, ADR-017). Merge semantics: only the named fields change, `null` clears a field. Each value has a `measured_at` (its own or the request's), which must not lie in the future or before the manufacturing date. Fields that are not dynamic fields of the unit are refused (`unknown_field`); so is the whole update when a value is invalid or the merged condition data fails a cross-field check. A value older than the stored one, or equal to it, is skipped and listed. Accepted values are stored as approved measurements and recorded as a `condition_update` lifecycle event; a published passport is re-published as the organisation's `condition_republish` setting says (immediately in this request, or by the daily job). ## Path parameters - **batteryId** `string (uuid)` (required) ## Request body (`application/json`) - **measured_at** `string (date-time)`: The measurement time of values without their own. - **note** `string`: Becomes the description of the lifecycle event. - **values** `ConditionValueInput[]` (required) - **field_key** `string` (required) - **measured_at** `string (date-time)`: When the value was measured; defaults to the update's `measured_at`. - **value** `any | null` (required): The value as JSON; null clears the field. ## Responses ### 200 (`application/json`) What was accepted, what was skipped, and the publication. - **accepted** `FieldValue[]` (required) - **checks** `ValueCheck[]` (required) - **evidence** `EvidenceRef[]`: Documents this revision rests on (set on approved revisions and supplier submissions). - **field_key** `string` (required) - **measured_at** `string (date-time)`: When a measured value (condition data, ADR-016) was measured. - **recorded_at** `string (date-time)` (required) - **recorded_by** `UserRef` - **registry_version** `string` (required) - **responsible** `UserRef` - **review_note** `string` - **review_status** `string` (required, one of: `captured`, `auto_checked`, `approved`, `rejected`) - **reviewed_at** `string (date-time)` - **reviewed_by** `UserRef` - **revision** `integer` (required) - **source** `string` (required, one of: `manual`, `import`, `supplier`, `api`, `system`) - **superseded_at** `string (date-time)` - **unit** `string` - **value** `any | null` (required): The value as JSON; null when the field was cleared. - **event** `LifecycleEvent` - **api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **battery_id** `string (uuid)` (required) - **created_at** `string (date-time)` (required) - **description** `string` - **evidence** `EvidenceRef[]` (required) - **field_keys** `string[]` (required): The condition fields this event updated. - **id** `string (uuid)` (required) - **occurred_on** `string (date)` (required) - **partner** `ServicePartnerRef` - **related_battery_id** `string (uuid)`: Second life: the successor (on the predecessor's event) or the predecessor (on the successor's). - **related_serial_number** `string` - **reported_by** `UserRef` - **source** `string` (required, one of: `manual`, `supplier`, `api`, `system`): `manual` (a member), `supplier` (a service-partner link), `api` (an API key, M7), `system`. - **type** `string` (required, one of: `placed_on_market`, `put_into_service`, `inspection`, `repair`, `component_replaced`, `condition_update`, `repurposed`, `reused`, `remanufactured`, `waste`, `recycled`, `custom`): What happened to a unit. `condition_update` is recorded by condition updates, `repurposed`, `reused` and `remanufactured` by `createSecondLifeBattery`; the others are reported. - **publication** `ConditionPublication` (required) - **blockers** `PublishBlocker[]` - **outcome** `string` (required, one of: `published`, `unchanged`, `blocked`, `scheduled`, `not_published`): `published` / `unchanged`: published immediately (or nothing changed); `blocked`: the gate refused, the passport is `update_needed`; `scheduled`: the daily job publishes it; `not_published`: the unit has no published passport. - **version** `PassportVersionSummary` - **skipped** `ConditionSkip[]` (required) - **field_key** `string` (required) - **reason** `string` (required, one of: `older_measurement`, `unchanged`): `older_measurement`: the stored value was measured later; `unchanged`: same value and time. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 409 (`application/problem+json`) The request conflicts with the current state (`conflict`), e.g. a stale `If-Match` version. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X PATCH "https://passport.kabin.sh/api/v1/batteries/{batteryId}/condition" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ -H "Content-Type: application/json" \ -d @body.json ``` --- # Version, content hash and status of the field registry in use > `GET /registry` · Scope: `read` · operationId `getRegistryInfo` Any signed-in user. ## Responses ### 200 (`application/json`) The registry identity. - **hash** `string` (required): SHA-256 of the canonical registry content. - **status** `string` (required, one of: `seed`, `verified`): `seed` until verified against primary sources (docs/registry/verification-log.md). - **version** `string` (required) ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/registry" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The editor schema of a battery category > `GET /registry/schema` · Scope: `read` · operationId `getRegistrySchema` Any signed-in user. Sections and fields with bilingual labels, help, tier, the requirement rule for the category (before dates, conditions and values are applied; per-target status comes from the completeness operations), validation schema and export paths. ## Query parameters - **category** `string` (required, one of: `ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`): Battery category per Regulation (EU) 2023/1542; drives registry requirements. ## Responses ### 200 (`application/json`) The schema. - **categories** `RegistryCategory[]` (required) - **key** `string` (required, one of: `ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`): Battery category per Regulation (EU) 2023/1542; drives registry requirements. - **label** `LabelText` (required): Text in German and English (ADR-010); clients pick the locale. - **category** `string` (required, one of: `ev`, `lmt`, `industrial`, `stationary`, `industrial_without_bms`): Battery category per Regulation (EU) 2023/1542; drives registry requirements. - **checks** `RegistryCheck[]` (required) - **fields** `string[]` (required) - **key** `string` (required) - **level** `string` (required, one of: `model`, `unit`) - **message** `LabelText` (required): Text in German and English (ADR-010); clients pick the locale. - **severity** `string` (required, one of: `fail`, `warn`) - **hash** `string` (required) - **org_facts** `RegistryOrgFact[]` (required) - **key** `string` (required) - **label** `LabelText` (required): Text in German and English (ADR-010); clients pick the locale. - **type** `string` (required, one of: `boolean`) - **sections** `RegistrySection[]` (required) - **din_spec** `string` - **fields** `RegistryField[]` (required) - **key** `string` (required) - **label** `LabelText` (required): Text in German and English (ADR-010); clients pick the locale. - **order** `integer` (required) - **status** `string` (required, one of: `seed`, `verified`): `seed` until verified against primary sources (docs/registry/verification-log.md). - **version** `string` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/registry/schema?category=…" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # The registry in use and how many of the organisation's passports need an update > `GET /registry/status` · Scope: `read` · operationId `getRegistryStatus` Any member. After a new registry version is deployed, a job re-evaluates the organisation's published passports; passports with a newly missing required value become `update_needed` (they stay online) and their versions get an `update_passports` task. ## Responses ### 200 (`application/json`) The status. - **reevaluation** `Job`: `created_by` names the member who started the job, `created_by_api_key` the API key that started an import validation (ADR-026); a job without either was started by the system (the registry re-evaluation). - **created_at** `string (date-time)` (required) - **created_by** `UserRef` - **created_by_api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **error_code** `string` (one of: `forbidden`, `not_found`, `conflict`, `invalid`, `internal_error`): Why a job failed (`forbidden`: the member who started it lost the role or left). - **file** `JobFile` - **finished_at** `string (date-time)` - **id** `string (uuid)` (required) - **import_id** `string (uuid)` - **kind** `string` (required, one of: `import_validate`, `import_commit`, `import_approve`, `bulk_publish`, `qr_export`, `registry_reevaluate`, `org_export`) - **progress** `JobProgress` (required) - **result** `JobResult`: The summary of a finished job; which members are set depends on the kind. - **started_at** `string (date-time)` - **status** `string` (required, one of: `queued`, `running`, `succeeded`, `failed`) - **registry_hash** `string` (required) - **registry_status** `string` (required, one of: `seed`, `verified`): `seed` until verified against primary sources (docs/registry/verification-log.md). - **registry_version** `string` (required) - **update_needed** `integer` (required): The organisation's passports in status `update_needed`. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/registry/status" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Manufacturing facilities of the active organisation > `GET /facilities` · Scope: `read` · operationId `listFacilities` Any member. Ordered by name, then id. ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. ## Responses ### 200 (`application/json`) One page of facilities. - **items** `Facility[]` (required) - **address** `Address` (required): A postal address (ADR-022). `street` is the first address line, street and house number as written in the country; `extra_line` the second (building, floor, c/o). `region` is the administrative area where the country's format has one (`getAddressFormats`; required for IT and ES). Postal codes are checked against the country's pattern. `place_id` and `confirmed_source` say how the address was entered; they are never part of signed or public passports. - **created_at** `string (date-time)` (required) - **gln** `string`: GS1 Global Location Number, 13 digits with a valid check digit. - **id** `string (uuid)` (required) - **name** `string` (required) - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **updated_at** `string (date-time)` (required) - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/facilities" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # A public document of a public passport > `GET /public/passports/{passportId}/documents/{documentId}` · public, no key · operationId `downloadPublicPassportDocument` No authentication. Only documents of the `public` tier that a public field of a published version names (for example the EU declaration of conformity); everything else is `404`. ## Path parameters - **passportId** `string (uuid)` (required): The UUID of the passport identifier `urn:dpp:{uuid}`. - **documentId** `string (uuid)` (required) ## Responses ### 200 (`*/*`) The file. `any` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 429 (`application/problem+json`) Too many attempts (`rate_limited`); retry after the number of seconds in the `Retry-After` header. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/public/passports/{passportId}/documents/{documentId}" ``` --- # The public passport of a unit, looked up by GTIN and serial number > `GET /public/dpps/by-product-id/{productId}` · public, no key · operationId `getPublicPassportByProductId` No authentication. Same response as `getPublicPassport`. ## Path parameters - **productId** `string` (required): `{gtin}:{serial}` (EN 18222 style), e.g. `04012345000023:SV2027-000184`; the GTIN may have 8, 12, 13 or 14 digits. ## Responses ### 200 (`application/json`) The passport. - **document** `PassportDocument`: A passport version projected to an access tier. The `public` projection is stored and signed at publication (ADR-013); verify it by canonicalising this object with RFC 8785 (JCS), hashing it with SHA-256 and checking the Ed25519 signature. - **category** `PassportCategory` (required) - **economic_operator** `PublicProfile`: The economic operator as shown on public passports. Every property is optional; publishing (M4) requires a complete profile. - **gs1_digital_link** `string` (required) - **gtin** `string` (required) - **model_name** `string` (required): The name of the battery model (the product name), for headings. - **passport_id** `string`: Absent only in a preview of a passport that has no identifier yet. - **predecessor_passport_id** `string`: Second life: the passport identifier of the battery this one was repurposed, reused or remanufactured from. - **restricted_fields_present** `string[]` (required): Keys of fields with a value above this tier. Keys only, never values. - **sections** `PassportSection[]` (required) - **serial_number** `string` (required) - **tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`): The access tier a passport document is projected to; each includes the tiers before it. - **version** `PassportDocumentVersion` (required) - **links** `PublicPassportLinks` (required) - **gs1_digital_link** `string` - **json** `string` (required) - **keys** `string` (required) - **page** `string` (required) - **predecessor** `string`: Second life: the public JSON of the passport of the battery this one was made from. - **restricted** `string`: The page where authorised parties sign in to read restricted tiers; only while the portal is enabled (ADR-018). - **successors** `string[]`: Second life: the public JSON of the passports of batteries made from this one. - **passport_id** `string` (required) - **status** `string` (required, one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **valid_until** `string (date-time)`: Set when this version was replaced by a later one (history by date). - **verification** `PassportSignature` - **algorithm** `string` (required, one of: `Ed25519`) - **canonicalization** `string` (required, one of: `RFC8785`) - **hash** `string` (required): `sha256:` of the canonical `document`. - **key_id** `string` (required) - **keys_url** `string` (required) - **signature** `string` (required): Ed25519 signature of the canonical `document` bytes (base64url, no padding). - **signed_at** `string (date-time)` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 429 (`application/problem+json`) Too many attempts (`rate_limited`); retry after the number of seconds in the `Retry-After` header. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/public/dpps/by-product-id/{productId}" ``` --- # The public passport as it was at a point in time > `GET /public/passports/{passportId}/version` · public, no key · operationId `getPublicPassportVersion` No authentication. The version with `published_at ≤ at < valid_until`; `404` before the first publication. Versions are immutable, so the response is cached for a day. ## Path parameters - **passportId** `string (uuid)` (required): The UUID of the passport identifier `urn:dpp:{uuid}`. ## Query parameters - **at** `string (date-time)` (required) ## Responses ### 200 (`application/json`) The version. - **document** `PassportDocument`: A passport version projected to an access tier. The `public` projection is stored and signed at publication (ADR-013); verify it by canonicalising this object with RFC 8785 (JCS), hashing it with SHA-256 and checking the Ed25519 signature. - **category** `PassportCategory` (required) - **economic_operator** `PublicProfile`: The economic operator as shown on public passports. Every property is optional; publishing (M4) requires a complete profile. - **gs1_digital_link** `string` (required) - **gtin** `string` (required) - **model_name** `string` (required): The name of the battery model (the product name), for headings. - **passport_id** `string`: Absent only in a preview of a passport that has no identifier yet. - **predecessor_passport_id** `string`: Second life: the passport identifier of the battery this one was repurposed, reused or remanufactured from. - **restricted_fields_present** `string[]` (required): Keys of fields with a value above this tier. Keys only, never values. - **sections** `PassportSection[]` (required) - **serial_number** `string` (required) - **tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`): The access tier a passport document is projected to; each includes the tiers before it. - **version** `PassportDocumentVersion` (required) - **links** `PublicPassportLinks` (required) - **gs1_digital_link** `string` - **json** `string` (required) - **keys** `string` (required) - **page** `string` (required) - **predecessor** `string`: Second life: the public JSON of the passport of the battery this one was made from. - **restricted** `string`: The page where authorised parties sign in to read restricted tiers; only while the portal is enabled (ADR-018). - **successors** `string[]`: Second life: the public JSON of the passports of batteries made from this one. - **passport_id** `string` (required) - **status** `string` (required, one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **valid_until** `string (date-time)`: Set when this version was replaced by a later one (history by date). - **verification** `PassportSignature` - **algorithm** `string` (required, one of: `Ed25519`) - **canonicalization** `string` (required, one of: `RFC8785`) - **hash** `string` (required): `sha256:` of the canonical `document`. - **key_id** `string` (required) - **keys_url** `string` (required) - **signature** `string` (required): Ed25519 signature of the canonical `document` bytes (base64url, no padding). - **signed_at** `string (date-time)` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 429 (`application/problem+json`) Too many attempts (`rate_limited`); retry after the number of seconds in the `Retry-After` header. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/public/passports/{passportId}/version?at=…" ``` --- # The public passport (current version) > `GET /public/passports/{passportId}` · public, no key · operationId `getPublicPassport` No authentication. Only the `public` tier, as stored when the version was published (ADR-013). Passports that are not public (`qr_locked`, `withdrawn`, `suspended`, `deleted_recycled`) answer with their status and no content. Cached for 5 minutes; rate-limited per client address. ## Path parameters - **passportId** `string (uuid)` (required): The UUID of the passport identifier `urn:dpp:{uuid}`. ## Responses ### 200 (`application/json`) The passport. - **document** `PassportDocument`: A passport version projected to an access tier. The `public` projection is stored and signed at publication (ADR-013); verify it by canonicalising this object with RFC 8785 (JCS), hashing it with SHA-256 and checking the Ed25519 signature. - **category** `PassportCategory` (required) - **economic_operator** `PublicProfile`: The economic operator as shown on public passports. Every property is optional; publishing (M4) requires a complete profile. - **gs1_digital_link** `string` (required) - **gtin** `string` (required) - **model_name** `string` (required): The name of the battery model (the product name), for headings. - **passport_id** `string`: Absent only in a preview of a passport that has no identifier yet. - **predecessor_passport_id** `string`: Second life: the passport identifier of the battery this one was repurposed, reused or remanufactured from. - **restricted_fields_present** `string[]` (required): Keys of fields with a value above this tier. Keys only, never values. - **sections** `PassportSection[]` (required) - **serial_number** `string` (required) - **tier** `string` (required, one of: `public`, `legitimate_interest`, `authority`): The access tier a passport document is projected to; each includes the tiers before it. - **version** `PassportDocumentVersion` (required) - **links** `PublicPassportLinks` (required) - **gs1_digital_link** `string` - **json** `string` (required) - **keys** `string` (required) - **page** `string` (required) - **predecessor** `string`: Second life: the public JSON of the passport of the battery this one was made from. - **restricted** `string`: The page where authorised parties sign in to read restricted tiers; only while the portal is enabled (ADR-018). - **successors** `string[]`: Second life: the public JSON of the passports of batteries made from this one. - **passport_id** `string` (required) - **status** `string` (required, one of: `draft`, `qr_locked`, `published`, `update_needed`, `suspended`, `withdrawn`, `deleted_recycled`): Status of a unit's passport (`docs/domain-model.md` §7). - **valid_until** `string (date-time)`: Set when this version was replaced by a later one (history by date). - **verification** `PassportSignature` - **algorithm** `string` (required, one of: `Ed25519`) - **canonicalization** `string` (required, one of: `RFC8785`) - **hash** `string` (required): `sha256:` of the canonical `document`. - **key_id** `string` (required) - **keys_url** `string` (required) - **signature** `string` (required): Ed25519 signature of the canonical `document` bytes (base64url, no padding). - **signed_at** `string (date-time)` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 429 (`application/problem+json`) Too many attempts (`rate_limited`); retry after the number of seconds in the `Retry-After` header. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/public/passports/{passportId}" ``` --- # Check the signatures of a version on the server > `GET /public/passports/{passportId}/verify` · public, no key · operationId `verifyPassport` No authentication. A convenience: the same check works offline with the public JSON and `/.well-known/batterypass/keys.json` (ADR-004, ADR-013). Without `version_no`, the current version. ## Path parameters - **passportId** `string (uuid)` (required): The UUID of the passport identifier `urn:dpp:{uuid}`. ## Query parameters - **version_no** `integer` ## Responses ### 200 (`application/json`) The result. - **checked_at** `string (date-time)` (required) - **key_id** `string` (required) - **public_document_valid** `boolean` (required) - **signed_at** `string (date-time)` (required) - **snapshot_valid** `boolean` (required) - **valid** `boolean` (required): Both signatures verify and both hashes match the stored documents. - **version_no** `integer` (required) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 429 (`application/problem+json`) Too many attempts (`rate_limited`); retry after the number of seconds in the `Retry-After` header. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/public/passports/{passportId}/verify" ``` --- # The organisation's changes, oldest first, for polling > `GET /changes` · Scope: `read` · operationId `listChanges` Any member, or an API key with the `read` scope (ADR-026). The organisation's changes in the order they became visible, oldest first, as `{id, occurred_at, entity_type, entity_id, action}`: no values, no actors, no personal data; read the entity for its current state. Keep `next_cursor` of the last page and ask again later: a change appears only once every transaction that started before it has finished, so a cursor never skips a change that commits late. `next_cursor` is set whenever the page has items, also on the last page; an empty page keeps the cursor you sent (`next_cursor` null). Without `cursor` the feed starts at the beginning of the organisation's history. ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. - **types** `string[]`: Only these actions (all when omitted). ## Responses ### 200 (`application/json`) One page of changes. - **items** `Change[]` (required) - **action** `string` (required, one of: `battery.created`, `passport.published`, `passport.republished`, `passport.withdrawn`, `model_version.approved`, `import.committed`, `import.approved`, `lifecycle_event.recorded`, `condition.updated`): What changed (ADR-026): `battery.created` (a unit was created), `passport.published` (a unit's first passport version), `passport.republished` (a later version), `passport.withdrawn`, `model_version.approved`, `import.committed` (its units exist), `import.approved` (its values were approved), `lifecycle_event.recorded` (an event other than a condition update), `condition.updated` (measured condition data was accepted). - **entity_id** `string (uuid)` (required) - **entity_type** `string` (required, one of: `battery`, `model_version`, `import`): The resource to read after a change: `battery` (`getBattery`, `getPassport`, `listLifecycleEvents`, `getCondition`), `model_version` (`getModelVersion`) or `import` (`getImport`). Passport changes name the unit. - **id** `string (uuid)` (required) - **occurred_at** `string (date-time)` (required) - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/changes" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Upload a spreadsheet of units > `POST /imports` · Scope: `import` · operationId `createImport` Editor and above, or an API key with the `import` scope (ADR-026). A CSV (UTF-8, UTF-16 or Windows-1252; `;`, `,`, tab or `|` separated) or XLSX file of at most 10 MB, 10 000 rows and 200 columns; the first non-empty row holds the headers. The answer is the import with a mapping proposed from the headers (registry CSV headers, aliases, labels; units such as `(kWh)` are converted), or taken from the import preset named in `preset` (the ERP registry): a preset maps the columns it knows by their headers and leaves the others unmapped. A key stages the import only: members of the organisation commit it and approve its values in the portal, so declared data still enters through a member's decision. ## Request body (`multipart/form-data`) - **file** `any` (required) - **preset** `string`: An import preset of the ERP registry (`getErpRegistry`, ADR-026): which column header of an ERP export maps to which registry field. - **sheet** `string`: The XLSX worksheet to read (the first one when omitted). ## Responses ### 201 (`application/json`) The import. - **approved_at** `string (date-time)` - **approved_by** `string (uuid)` - **committed_at** `string (date-time)` - **created_at** `string (date-time)` (required) - **created_by** `UserRef` - **display_name** `string` (required) - **id** `string (uuid)` (required) - **created_by_api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **id** `string (uuid)` (required) - **name** `string` (required) - **filename** `string` (required) - **format** `string` (required, one of: `csv`, `xlsx`) - **headers** `string[]` (required) - **id** `string (uuid)` (required) - **mapping** `ColumnMapping[]` (required) - **auto** `boolean`: Proposed by the server from the header. - **column** `integer` (required): The 0-based column of the file. - **header** `string`: The column's header (set by the server). - **target** `string`: A target of `listImportTargets`: a field key, `key[n].property` (list item n, 1-based), `key.property` (object property) or `model_version`. Empty or missing: the column is ignored. - **unit** `string`: The unit the column's numbers are written in when it differs from the field's (e.g. `kWh` for a field in Wh). - **options** `ImportOptions` (required) - **battery_model_id** `string (uuid)`: The model of rows without a model column. - **facility_id** `string (uuid)`: The place of manufacture of rows without one. - **preset** `string`: An import preset of the ERP registry (`getErpRegistry`, ADR-026): which column header of an ERP export maps to which registry field. - **row_count** `integer` (required): Data rows (empty rows are left out). - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **sample_rows** `string[][]` (required): The first data rows of the file as read (at most 5), to show example values while mapping. - **sheet** `string`: The XLSX worksheet that was read. - **size_bytes** `integer (int64)` (required) - **status** `string` (required, one of: `uploaded`, `validating`, `validated`, `committing`, `committed`, `failed`): `uploaded` (mapping can change) → `validating` → `validated` → `committing` → `committed`; `failed` when the file could not be validated (validate again). A changed mapping sends a validated import back to `uploaded`. - **summary** `ImportSummary` - **approved** `integer` (required): Imported values approved by import approvals. - **created** `integer` (required): Units the commit created. - **duplicates** `integer` (required): Rows whose serial number repeats an earlier row or an existing unit; not imported. - **errors** `integer` (required): Rows with errors; not imported. - **not_approved** `integer` (required): Imported values the last approval left for individual review. - **rows** `integer` (required) - **valid** `integer` (required): Rows without messages. - **warnings** `integer` (required): Rows with warnings only; they are imported. - **updated_at** `string (date-time)` (required) - **validated_at** `string (date-time)` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 413 (`application/problem+json`) The request body exceeds the size limit (`payload_too_large`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X POST "https://passport.kabin.sh/api/v1/imports" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # One import with its mapping and summary > `GET /imports/{importId}` · Scope: `import` · operationId `getImport` Any member, or an API key with the `import` scope (ADR-026). Poll it after `validateImport`: the status becomes `validated` (or `failed`) and `summary` counts valid, warning, error and duplicate rows. ## Path parameters - **importId** `string (uuid)` (required) ## Responses ### 200 (`application/json`) The import. - **approved_at** `string (date-time)` - **approved_by** `string (uuid)` - **committed_at** `string (date-time)` - **created_at** `string (date-time)` (required) - **created_by** `UserRef` - **display_name** `string` (required) - **id** `string (uuid)` (required) - **created_by_api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **id** `string (uuid)` (required) - **name** `string` (required) - **filename** `string` (required) - **format** `string` (required, one of: `csv`, `xlsx`) - **headers** `string[]` (required) - **id** `string (uuid)` (required) - **mapping** `ColumnMapping[]` (required) - **auto** `boolean`: Proposed by the server from the header. - **column** `integer` (required): The 0-based column of the file. - **header** `string`: The column's header (set by the server). - **target** `string`: A target of `listImportTargets`: a field key, `key[n].property` (list item n, 1-based), `key.property` (object property) or `model_version`. Empty or missing: the column is ignored. - **unit** `string`: The unit the column's numbers are written in when it differs from the field's (e.g. `kWh` for a field in Wh). - **options** `ImportOptions` (required) - **battery_model_id** `string (uuid)`: The model of rows without a model column. - **facility_id** `string (uuid)`: The place of manufacture of rows without one. - **preset** `string`: An import preset of the ERP registry (`getErpRegistry`, ADR-026): which column header of an ERP export maps to which registry field. - **row_count** `integer` (required): Data rows (empty rows are left out). - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **sample_rows** `string[][]` (required): The first data rows of the file as read (at most 5), to show example values while mapping. - **sheet** `string`: The XLSX worksheet that was read. - **size_bytes** `integer (int64)` (required) - **status** `string` (required, one of: `uploaded`, `validating`, `validated`, `committing`, `committed`, `failed`): `uploaded` (mapping can change) → `validating` → `validated` → `committing` → `committed`; `failed` when the file could not be validated (validate again). A changed mapping sends a validated import back to `uploaded`. - **summary** `ImportSummary` - **approved** `integer` (required): Imported values approved by import approvals. - **created** `integer` (required): Units the commit created. - **duplicates** `integer` (required): Rows whose serial number repeats an earlier row or an existing unit; not imported. - **errors** `integer` (required): Rows with errors; not imported. - **not_approved** `integer` (required): Imported values the last approval left for individual review. - **rows** `integer` (required) - **valid** `integer` (required): Rows without messages. - **warnings** `integer` (required): Rows with warnings only; they are imported. - **updated_at** `string (date-time)` (required) - **validated_at** `string (date-time)` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/imports/{importId}" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Imports of the active organisation, newest first > `GET /imports` · Scope: `import` · operationId `listImports` Any member, or an API key with the `import` scope (ADR-026). An import staged with a key names the key in `created_by_api_key` instead of `created_by`. ## Query parameters - **limit** `integer` (default: 50): Maximum number of items to return. - **cursor** `string`: Opaque cursor from a previous page's `next_cursor`. ## Responses ### 200 (`application/json`) One page of imports. - **items** `Import[]` (required) - **approved_at** `string (date-time)` - **approved_by** `string (uuid)` - **committed_at** `string (date-time)` - **created_at** `string (date-time)` (required) - **created_by** `UserRef` - **created_by_api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **filename** `string` (required) - **format** `string` (required, one of: `csv`, `xlsx`) - **headers** `string[]` (required) - **id** `string (uuid)` (required) - **mapping** `ColumnMapping[]` (required) - **options** `ImportOptions` (required) - **preset** `string`: An import preset of the ERP registry (`getErpRegistry`, ADR-026): which column header of an ERP export maps to which registry field. - **row_count** `integer` (required): Data rows (empty rows are left out). - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **sample_rows** `string[][]` (required): The first data rows of the file as read (at most 5), to show example values while mapping. - **sheet** `string`: The XLSX worksheet that was read. - **size_bytes** `integer (int64)` (required) - **status** `string` (required, one of: `uploaded`, `validating`, `validated`, `committing`, `committed`, `failed`): `uploaded` (mapping can change) → `validating` → `validated` → `committing` → `committed`; `failed` when the file could not be validated (validate again). A changed mapping sends a validated import back to `uploaded`. - **summary** `ImportSummary` - **updated_at** `string (date-time)` (required) - **validated_at** `string (date-time)` - **next_cursor** `string | null` (required): Cursor for the next page, or null when this is the last page. ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X GET "https://passport.kabin.sh/api/v1/imports" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Map the columns of an import > `PUT /imports/{importId}/mapping` · Scope: `import` · operationId `updateImportMapping` Editor and above, or an API key with the `import` scope (ADR-026), while the import is not being processed or committed. Each target at most once; `unit` is the unit a column's numbers are written in when it differs from the field's. With `preset` (an import preset of the ERP registry) the mapping is taken from the preset by the file's headers and `mapping` must be empty. A validated import must be validated again. ## Path parameters - **importId** `string (uuid)` (required) ## Headers - **If-Match** `string` (required): The `row_version` the client last read, as a quoted string, e.g. `"3"`. ## Request body (`application/json`) - **mapping** `ColumnMapping[]` - **auto** `boolean`: Proposed by the server from the header. - **column** `integer` (required): The 0-based column of the file. - **header** `string`: The column's header (set by the server). - **target** `string`: A target of `listImportTargets`: a field key, `key[n].property` (list item n, 1-based), `key.property` (object property) or `model_version`. Empty or missing: the column is ignored. - **unit** `string`: The unit the column's numbers are written in when it differs from the field's (e.g. `kWh` for a field in Wh). - **options** `ImportOptions` - **battery_model_id** `string (uuid)`: The model of rows without a model column. - **facility_id** `string (uuid)`: The place of manufacture of rows without one. - **preset** `string`: An import preset of the ERP registry (`getErpRegistry`, ADR-026): which column header of an ERP export maps to which registry field. ## Responses ### 200 (`application/json`) The import. - **approved_at** `string (date-time)` - **approved_by** `string (uuid)` - **committed_at** `string (date-time)` - **created_at** `string (date-time)` (required) - **created_by** `UserRef` - **display_name** `string` (required) - **id** `string (uuid)` (required) - **created_by_api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **id** `string (uuid)` (required) - **name** `string` (required) - **filename** `string` (required) - **format** `string` (required, one of: `csv`, `xlsx`) - **headers** `string[]` (required) - **id** `string (uuid)` (required) - **mapping** `ColumnMapping[]` (required) - **auto** `boolean`: Proposed by the server from the header. - **column** `integer` (required): The 0-based column of the file. - **header** `string`: The column's header (set by the server). - **target** `string`: A target of `listImportTargets`: a field key, `key[n].property` (list item n, 1-based), `key.property` (object property) or `model_version`. Empty or missing: the column is ignored. - **unit** `string`: The unit the column's numbers are written in when it differs from the field's (e.g. `kWh` for a field in Wh). - **options** `ImportOptions` (required) - **battery_model_id** `string (uuid)`: The model of rows without a model column. - **facility_id** `string (uuid)`: The place of manufacture of rows without one. - **preset** `string`: An import preset of the ERP registry (`getErpRegistry`, ADR-026): which column header of an ERP export maps to which registry field. - **row_count** `integer` (required): Data rows (empty rows are left out). - **row_version** `integer (int64)` (required): Optimistic-concurrency version; send it back as `If-Match` when updating. - **sample_rows** `string[][]` (required): The first data rows of the file as read (at most 5), to show example values while mapping. - **sheet** `string`: The XLSX worksheet that was read. - **size_bytes** `integer (int64)` (required) - **status** `string` (required, one of: `uploaded`, `validating`, `validated`, `committing`, `committed`, `failed`): `uploaded` (mapping can change) → `validating` → `validated` → `committing` → `committed`; `failed` when the file could not be validated (validate again). A changed mapping sends a validated import back to `uploaded`. - **summary** `ImportSummary` - **approved** `integer` (required): Imported values approved by import approvals. - **created** `integer` (required): Units the commit created. - **duplicates** `integer` (required): Rows whose serial number repeats an earlier row or an existing unit; not imported. - **errors** `integer` (required): Rows with errors; not imported. - **not_approved** `integer` (required): Imported values the last approval left for individual review. - **rows** `integer` (required) - **valid** `integer` (required): Rows without messages. - **warnings** `integer` (required): Rows with warnings only; they are imported. - **updated_at** `string (date-time)` (required) - **validated_at** `string (date-time)` ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 409 (`application/problem+json`) The request conflicts with the current state (`conflict`), e.g. a stale `If-Match` version. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X PUT "https://passport.kabin.sh/api/v1/imports/{importId}/mapping" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \ -H "Content-Type: application/json" \ -d @body.json ``` --- # Start validating every row of an import > `POST /imports/{importId}/validate` · Scope: `import` · operationId `validateImport` Editor and above, or an API key with the `import` scope (ADR-026). A job reads every row with the mapping, validates it with the editor's validators and finds duplicate serial numbers in the file and in the database (after removing surrounding spaces and invisible characters). Requires a serial number column and a model column or `options.battery_model_id`. Follow the import with `getImport` until its status is `validated` or `failed`. ## Path parameters - **importId** `string (uuid)` (required) ## Responses ### 202 (`application/json`) The validation job. - **created_at** `string (date-time)` (required) - **created_by** `UserRef` - **display_name** `string` (required) - **id** `string (uuid)` (required) - **created_by_api_key** `ApiKeyRef`: An organisation API key (ADR-017) that reported an event (source `api`) or staged an import (ADR-026). - **id** `string (uuid)` (required) - **name** `string` (required) - **error_code** `string` (one of: `forbidden`, `not_found`, `conflict`, `invalid`, `internal_error`): Why a job failed (`forbidden`: the member who started it lost the role or left). - **file** `JobFile` - **content_type** `string` (required) - **expires_at** `string (date-time)` (required) - **filename** `string` (required) - **size** `integer (int64)` (required) - **finished_at** `string (date-time)` - **id** `string (uuid)` (required) - **import_id** `string (uuid)` - **kind** `string` (required, one of: `import_validate`, `import_commit`, `import_approve`, `bulk_publish`, `qr_export`, `registry_reevaluate`, `org_export`) - **progress** `JobProgress` (required) - **done** `integer` (required) - **total** `integer` (required) - **result** `JobResult`: The summary of a finished job; which members are set depends on the kind. - **approval** `ImportApprovalSummary` - **detail** `string`: Why a failed job failed (English). - **export** `ExportSummary`: What a full organisation export contains. - **import_summary** `ImportSummary` - **outcomes** `JobOutcomeCounts` - **reevaluation** `ReevaluationSummary` - **units** `integer` - **started_at** `string (date-time)` - **status** `string` (required, one of: `queued`, `running`, `succeeded`, `failed`) ### 400 (`application/problem+json`) The request is malformed or failed validation (`validation_error`), or a token is invalid or expired (`invalid_token`). ### 401 (`application/problem+json`) The API key is missing, unknown or revoked (`unauthenticated`). ### 403 (`application/problem+json`) The API key may not perform this action (`forbidden`): it lacks the operation's scope, or the action needs more than a viewer's rights. ### 404 (`application/problem+json`) The resource does not exist in the organisation of the API key, or the public passport is unknown (`not_found`). ### 409 (`application/problem+json`) The request conflicts with the current state (`conflict`), e.g. a stale `If-Match` version. ### default (`application/problem+json`) Any other problem, e.g. `internal_error`. ## Example ```bash title="cURL" curl -X POST "https://passport.kabin.sh/api/v1/imports/{importId}/validate" \ -H "Authorization: Bearer $BATTERYPASS_API_KEY" ``` --- # Changelog > What changed in the integration API and in this documentation. New entries are at the top. The history is also available as an [RSS feed](https://passport.kabin.sh/docs/en/changelog/rss.xml). ## 0.10.0 _2026-10-24 · API · Contract · Integrations_ Integrations can now bring units in and follow changes without scanning everything. - A third key scope, `import`: a key with it uploads, maps and validates spreadsheet imports of units ([`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import), [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import), [`listImports`](https://passport.kabin.sh/docs/en/api-reference/imports/list-imports), [`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping), [`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import)). Committing the import and approving its values stay with members in the portal. An import staged with a key names it in `created_by_api_key`; `created_by` is then absent. - Import presets: `preset` on `createImport` and `updateImportMapping` maps the columns of a known ERP export by their headers. - The change feed [`listChanges`](https://passport.kabin.sh/docs/en/api-reference/changes/list-changes) (scope `read`): the organisation's changes oldest first, without values, actors or personal data, safe to poll with a stored cursor. - New [integration guides](https://passport.kabin.sh/docs/en/integrations): the security model, five workflows, iPaaS, standards and a page per ERP system from the ERP registry, with how and when each interface was checked. ## Developer documentation _2026-10-24 · Documentation_ The developer documentation moves to `/docs/` and replaces the former `/api-docs` page (which now redirects to the API reference). - Getting started, concepts, guides and recipes in German and English. - The API reference is generated from the published contract, with a playground, code samples in cURL, JavaScript, Python and Go, and the contract to download ([JSON](https://passport.kabin.sh/docs/openapi.json), [YAML](https://passport.kabin.sh/docs/openapi.yaml)). - Every page is available as Markdown (the page's address plus `.md`), along with [`llms.txt`](https://passport.kabin.sh/docs/en/llms.txt) and [`llms-full.txt`](https://passport.kabin.sh/docs/en/llms-full.txt) for language models. ## 0.9.0 _2026-10-24 · API · Contract_ Addresses follow the format of their European country. All changes are compatible. - `Address` gains `extra_line` (the second address line: building, floor, c/o) and `region` (the administrative area, where the country's format has one). Both are optional and appear in the facilities of [`listFacilities`](https://passport.kabin.sh/docs/en/api-reference/facilities/list-facilities) and in the operator's public profile in passports. - `listFacilities` also returns `place_id` and `confirmed_source`: how a member entered the address (by hand, from the address search or confirmed by the address check). They never appear in signed snapshots or public passports. - The new problem code `address_lookup_unavailable` (`503`) belongs to the portal's address search, which does not accept API keys. ## 0.8.0 _2026-10-24 · API · Contract_ Only the part of the contract that integrations can use is published now: the 32 operations that accept an API key and the 5 public passport reads. Sign-in, sessions, key management and every other portal operation are no longer in `/api/v1/openapi.json` and `.yaml`; they may change without notice. - Organisation API keys (`Authorization: Bearer bp_live_…`) with the scopes `read` and `condition`. Owners create keys in the portal; a key has a viewer's rights. - With `condition`, battery management systems and service partners report condition data (`updateCondition`) and lifecycle events (`createLifecycleEvent`). - Limits: 600 requests per minute for all keys of an organisation together, 30 attempts per 15 minutes for unknown keys, 300 public reads per minute and address; `429` with `Retry-After`.