API documentation

REST API v1

Maintain models, passports, lifecycle events, telemetry and evidence documents from your own systems. JSON over HTTPS, scoped keys, a test mode that stores nothing.

Base URL
https://app.batteriepasswerk.com/api/v1
Version
1.1.0
Version

There is only version 1 today. Older versions become selectable here once they exist.

Overview

The API models the same objects as the dashboard: a model is the master-data level of a product, a passport belongs to exactly one physical battery. Events and measurements hang off the passport. Everything runs through one base URL and every response is JSON, errors included.

One key, one company

The key determines the company. Foreign identifiers return 404, not 403 - there is no way to infer other companies' data from a key.

No DELETE

Battery passports are subject to retention. Version 1 knows GET, POST and PATCH. End of life is reported as an event.

Server-owned fields

Readiness, battery status, short code and registry state are computed by the server. Those fields are readable but not writable.

Same rules as the dashboard

Quotas, mandatory fields and roles apply identically. There is no way to create something through the API that the dashboard would forbid.

Step 1: create a key

API keys are created in the dashboard only, and only by an account with the admin role. The owner always has admin.

  1. Open Integrations

    In the dashboard, left navigation, under Administration. Only admins see this entry.

  2. Create an API key

    In the API access panel press Create API key. Give it a label that names the system, for example SAP-North-Prod.

  3. Pick mode and scopes

    Test for development, live for production. Then a preset or individual scopes, and optionally an expiry of 30, 90 or 365 days.

  4. Store the plaintext once

    The key is shown exactly once. Only a SHA-256 hash is stored, so nobody can read it back later, ourselves included.

Treat the key like a password: not in repositories, not in the front end, not in logs. If it leaks, rotate it in the dashboard - the old key stops working immediately and the new one is shown once.

Step 2: first call

GET /me is the connection test. The response reports company, plan, mode, effective scopes, rate limit and the remaining quota for the current contract year. If this call works, key, headers and network path are correct.

export BPW_API_KEY="bpw_test_…"

curl -s https://app.batteriepasswerk.com/api/v1/me \
  -H "Authorization: Bearer $BPW_API_KEY"

Step 3: try it in the console

The Integrations page links to the API console. Pick an endpoint from the OpenAPI description, fill in parameters, send with your session instead of a key and read the response as a collapsible JSON tree. Every call can be copied as cURL straight into a terminal. Write calls run as a dry run there by default.

Authentication

Every request carries the Authorization header with the Bearer scheme. There are two kinds of key, told apart by their prefix.

KeyShapePurpose
Livebpw_live_ + 48 hex charactersProduction, actually writes
Testbpw_test_ + 48 hex charactersDevelopment and sign-off, never persists

The API also accepts the session token of a signed-in dashboard user; scopes then follow from the role. That is how the API console works and is not intended for integrations. A missing header or an invalid key returns 401 with WWW-Authenticate. After 30 failed attempts per IP and minute the API answers 429.

Live, test and dry run

There is deliberately no separate sandbox database. A test key works on your real master data and checks scopes, mandatory fields, serials and quotas exactly as in production, but stores nothing.

Live keyTest key
Readingreal datareal data
Writingpersisted, 201fully validated, 200 with dry_run: true
Quotaconsumedchecked only
Logyesyes, marked as dry run
Rate limit600 / minute60 / minute

A live key can send a single request as a trial too: header X-BPW-Dry-Run: true. The response headers X-BPW-Mode and X-BPW-Dry-Run always state what actually applied.

Scopes

Every key carries exactly the permissions the calling system needs. If one is missing the route returns 403 with the code insufficient_scope.

ScopeAllows
models:readRead models, validate a draft
models:writeCreate and change models
passes:readRead passports and events, including public_url, gs1_link and short_url for your own QR codes
passes:writeCreate and change passports, report events
telemetry:readRead the telemetry time series per passport (since 1.1.0)
telemetry:writeReport measurements
certificates:readRead and download evidence documents
suppliers:readRead suppliers, data requests and delivered values (since 1.1.0)
suppliers:writeInvite suppliers (since 1.1.0)
audit:readRead the audit trail (since 1.1.0)

GET /me, GET /field-catalog and GET /openapi.json need no scope. Dashboard presets: read only (every read scope), ERP sync, BMS telemetry (read and report), all, custom; the dashboard explains the endpoints each scope unlocks. At most ten active keys per company; every action on a key is recorded in the audit trail with person and timestamp.

Plans

The check runs on every single call, not just when the key is created. A plan change takes effect immediately: after moving from Enterprise to Pro a live key keeps telemetry:read and telemetry:write only.

PlanTest keyLive key
Pilot, Starteryes, full functionalityno
Proyestelemetry:read and telemetry:write only
Enterpriseyesall scopes
Archive, Lifetimeyes, read onlyno

In retention plans every write route returns 403 with tenant_read_only. Telemetry additionally requires the Pro plan or higher, independent of the key mode.

Requests and responses

Single objects come back as a JSON object, lists inside an envelope with data, has_more and next_cursor.

Time
Timestamps are ISO 8601 in UTC with milliseconds, calendar dates are YYYY-MM-DD. Input is normalised; a date like 2026-02-30 is rejected.
null versus omitted
On POST and PATCH an omitted field stays unchanged, null clears the field. Empty strings become null.
Unknown fields
Are rejected, not ignored. A typo in the ERP surfaces immediately instead of silently losing data.
Identifiers
Lower-case UUIDs. The usual route from an ERP record to an object is GET /passes?serial=… or GET /models?code=…
Charset and caching
UTF-8, Content-Type application/json. Every response carries Cache-Control: no-store.

Response headers

HeaderMeaning
X-Request-IdIdentifier of the call, also present in every error body and in the dashboard API log.
X-RateLimit-Limit / -Remaining / -ResetBudget of the current window, reset as Unix seconds.
X-BPW-Modelive or test, depending on the key used.
X-BPW-Dry-Runtrue when this call stored nothing.
Idempotent-Replayedtrue when a stored response was replayed.
Retry-AfterOnly on 429: wait time in seconds.

Errors

Every error uses the same envelope. type groups roughly by HTTP status, code is stable and meant for program logic, message is English and may change. param names the first offending field, inside a batch for example items[3].serial, details lists them all. Retry 429 and 5xx with backoff, never retry 4xx automatically.

400 Bad Request
HTTP/1.1 400 Bad Request
X-Request-Id: req_7f3c9a21e4b84c60

{
  "error": {
    "type": "invalid_request",
    "code": "validation_failed",
    "message": "One or more fields are invalid.",
    "param": "energy_kwh",
    "details": [
      { "param": "energy_kwh", "code": "invalid_type", "message": "energy_kwh must be a number." },
      { "param": "gtin", "code": "invalid_gtin", "message": "gtin must be a valid GS1 GTIN." }
    ],
    "request_id": "req_7f3c9a21e4b84c60"
  }
}
HTTPcodeWhen
400unknown_fieldField is not part of the schema. details names every unknown field.
400validation_failedType errors in model fields, details lists every affected field.
400missing_fieldA required field is missing, for example code, model_id or serial.
400invalid_enumValue is not in the allowed list.
400invalid_serialSerial violates the GS1 AI 21 character set or is too long.
400invalid_gtinThe GTIN check digit is invalid.
400invalid_date / invalid_timestampNot a valid calendar date or not an ISO 8601 timestamp.
400duplicate_serial_in_batchTwo entries in the same batch carry the same serial.
400out_of_range / no_measurementTelemetry value out of range, or no measurement supplied at all.
400empty_patchPATCH without a single changeable field.
401missing_authorizationNo Authorization header sent.
401invalid_api_keyKey unknown or malformed.
401api_key_revoked / api_key_expiredThe key was revoked or has expired.
403plan_requiredThe plan does not allow this mode or scope.
403insufficient_scopeThe key lacks the scope the route requires.
403tenant_read_onlyRetention plan, write calls are blocked.
404model_not_found / pass_not_found / certificate_not_foundThe object does not exist inside your company. Foreign identifiers also return 404.
404route_not_foundThe path does not exist. Typo or a missing /v1.
405method_not_allowedMethod not allowed for this path, the Allow header names the allowed ones.
409duplicate_code / duplicate_serialModel code or serial already exists inside your company.
409limit_reachedThe plan quota is reached. GET /me shows the current state.
422idempotency_key_reusedSame Idempotency-Key but a different payload.
429rate_limit_exceededWindow exhausted. Retry-After gives the wait in seconds.
500internal_errorUnexpected error. Please report the request_id.

Pagination

Lists return at most 200 entries per page, 50 by default. Sorting is newest first by creation time, telemetry by measurement time. The cursor is opaque and works on timestamp and identifier rather than offsets: records created during a run neither shift nor skip anything. For incremental reconciliation remember the largest updated_at of the last page and send it as updated_since next time.

pagination.py
import requests

BASE = "https://app.batteriepasswerk.com/api/v1"
H = {"Authorization": f"Bearer {KEY}"}

def iterate(path, **params):
    cursor = None
    while True:
        r = requests.get(f"{BASE}{path}", headers=H,
                         params={**params, "limit": 200, "cursor": cursor})
        r.raise_for_status()
        page = r.json()
        yield from page["data"]
        if not page["has_more"]:
            return
        cursor = page["next_cursor"]

# Inkrementell: nur was sich seit dem letzten Lauf geändert hat
for p in iterate("/passes", updated_since="2026-09-01T00:00:00Z"):
    print(p["serial"], p["lifecycle_status"])

Rate limits

Fixed windows of 60 seconds. Every response reports the remaining budget; exceeding it returns 429 with Retry-After. A batch of 500 passports counts as one request, so mass serialisation is rarely the constraint.

CallerLimit
Live key600 requests per minute
Test key60 per minute
Console session120 per minute
Failed authentications30 per minute and IP
GET /openapi.json60 per minute and IP

Idempotency

Every POST accepts the Idempotency-Key header with up to 255 characters, for example the document number from the ERP.

  • First execution: normal processing, the response is stored for 24 hours under company, key and idempotency key.
  • Retry with an identical request: the stored response plus the header Idempotent-Replayed: true, without executing again.
  • Retry with a different payload: 422 with the code idempotency_key_reused.
  • Only 2xx and 4xx are stored. After 429 or 5xx you may retry with the same key.
# Erster Versuch läuft in einen Timeout - Ergebnis unbekannt
curl -X POST https://app.batteriepasswerk.com/api/v1/passes \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Idempotency-Key: los-2026-09-0042" \
  -H "Content-Type: application/json" \
  -d '{ "items": [ … ] }'

# Gefahrlose Wiederholung mit demselben Schlüssel
# → 201 mit derselben Antwort, zusätzlich: Idempotent-Replayed: true

Account and catalog

Eighteen endpoints. Each names the required scope, its parameters and a complete example. All paths are relative to the base URL.

GET/meScope: none

Connection test and self-information

Returns company, plan, mode, effective scopes, rate limit and the quota for the current contract year. The first call of every integration.

Response
{
  "api_version": "v1",
  "mode": "test",
  "dry_run": true,
  "tenant": { "id": "8f21…c4", "name": "Muster GmbH", "plan": "enterprise" },
  "principal": {
    "kind": "api_key",
    "key": { "id": "0d4e…91", "label": "SAP-Nord-Prod", "prefix": "bpw_test_a1b2c3d4", "expires_at": null }
  },
  "scopes": ["models:read", "models:write", "passes:read", "passes:write", "telemetry:write", "certificates:read"],
  "read_only": false,
  "rate_limit": { "limit": 60, "window_seconds": 60 },
  "usage": {
    "contract_year_start": "2026-03-01T00:00:00.000Z",
    "models": { "used": 3, "max": null, "remaining": null },
    "passes": { "used": 18240, "quota": 25000, "quota_source": "plan",
                "remaining": 6760, "enforcement": "overage",
                "overage_units": 0, "overage_price_eur": 0.05, "blocked": false }
  },
  "server_time": "2026-09-09T08:14:02.118Z"
}
GET/field-catalogScope: none

Mandatory-field rules and EU data points

Every mandatory-field rule with the numbers of the 71 official EU data points, the legal basis, applicability and the API fields that satisfy it. This is how an ERP maps the data points onto its own fields.

Response
{
  "rules_version": 9,
  "categories": ["lmt", "bess", "ind", "ev", "device", "sli"],
  "sections": ["identity", "conformity", "carbon", "materials", "circularity", "performance", "dynamic"],
  "data": [
    {
      "key": "gtin",
      "section": "identity",
      "resource": "model",
      "fields": ["gtin"],
      "fill_rule": "all",
      "eu_datapoints": [1],
      "legal_ref": "BR Art. 77(3)",
      "applicability": "always",
      "blocker": true,
      "second_life_exempt": false,
      "condition_note": { "de": null, "en": null }
    }
  ]
}
GET/openapi.jsonScope: none

Machine-readable contract

OpenAPI 3.1, publicly available without a key. The basis for client generators and for the API console in the dashboard.

Response
{
  "openapi": "3.1.0",
  "info": { "title": "Batteriepasswerk REST API", "version": "1.0.0" },
  "servers": [{ "url": "https://app.batteriepasswerk.com/api/v1" }],
  "paths": { "/me": { "get": { "operationId": "getMe" } } }
}

Models

GET/modelsScope: models:read

List models

Newest first, cursor pagination. Use updated_since for incremental reconciliation.

Parameters

NameInTypeDescription
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
codequerystringExact model code, at most one hit.
categoryquerylmt | bess | ind | ev | device | sliBattery category as defined by the regulation.
statusqueryready | review | pending | critInternal editing status.
updated_sincequeryISO 8601Only models changed at or after this timestamp.
curl -s "https://app.batteriepasswerk.com/api/v1/models?category=bess&limit=2" \
  -H "Authorization: Bearer $BPW_API_KEY"
GET/models/{id}Scope: models:read

Read one model

All fields plus the server-computed readiness. Unknown or foreign identifiers return 404.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
curl -s https://app.batteriepasswerk.com/api/v1/models/f76b5107-d6c3-4527-8d04-0c89209bede1 \
  -H "Authorization: Bearer $BPW_API_KEY"
POST/modelsScope: models:write

Create a model

The body carries the writable model fields (see the field reference). Unknown fields are rejected. Returns 201 with the record and the current quota; with a test key 200 with dry_run and id: null.

Parameters

NameInTypeDescription
code *bodystringModel code, unique per company.
categorybodylmt | bess | ind | ev | device | sliDetermines which mandatory fields apply.
gtinbodystringGS1 GTIN with a valid check digit, drives the public address.
second_lifebodybooleanSecond-life exemption under Art. 7(5) and 8(4).
applicability_flagsbodyobject of booleanDeclared non-applicability per rule of the field catalog.
curl -s -X POST https://app.batteriepasswerk.com/api/v1/models \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: erp-model-4711" \
  -d '{
        "code": "BESS-10",
        "category": "bess",
        "name": "Home Storage 10",
        "chemistry": "LFP",
        "energy_kwh": 10.2,
        "nominal_voltage_v": 51.2,
        "gtin": "04012345678901",
        "ce_marked": true,
        "separate_collection": true
      }'
PATCH/models/{id}Scope: models:write

Update a model

Only the fields you send change, null clears a field, omitted fields stay untouched. Readiness is recomputed.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
curl -s -X PATCH https://app.batteriepasswerk.com/api/v1/models/f76b5107-… \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "co2_kg_per_kwh": 61.4, "co2_study_url": "https://example.org/lca.pdf" }'
POST/models/validateScope: models:read

Validate a draft, store nothing

Stateless: returns the readiness a model with these values would have and lists every missing field. Ideal as a pre-check in the ERP before anything is written.

curl -s -X POST https://app.batteriepasswerk.com/api/v1/models/validate \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "BESS-10", "category": "bess", "energy_kwh": 10.2 }'

Passports

GET/passesScope: passes:read

List passports

Newest first. serial finds exactly one passport, the usual way from an ERP record to the passport id.

Parameters

NameInTypeDescription
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
model_idqueryuuidOnly passports of this model.
serialquerystringExact serial number.
batchquerystringBatch or lot.
statusqueryready | review | pending | critInternal editing status.
lifecycle_statusqueryoriginal | repurposed | re-used | remanufactured | wasteLegal battery status, EU data point 67.
created_since / updated_sincequeryISO 8601Time filters, applied as greater-or-equal.
curl -s "https://app.batteriepasswerk.com/api/v1/passes?serial=SN-000123" \
  -H "Authorization: Bearer $BPW_API_KEY"
GET/passes/{id}Scope: passes:read

Read one passport

One passport including derived battery status, latest telemetry value, registry state and the public addresses for QR printing and the short link.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
curl -s https://app.batteriepasswerk.com/api/v1/passes/ffdad688-745f-475e-aaaa-e23112c0f041 \
  -H "Authorization: Bearer $BPW_API_KEY"
POST/passesScope: passes:write

Create passports, single or batched

One object creates one passport, items with up to 500 entries creates a batch. A batch counts as one request against the rate limit. Duplicate serials inside a batch are always an error.

Parameters

NameInTypeDescription
model_id *bodyuuidMust be a model of your company.
serial *bodystring, max. 20GS1 AI 21 character set, unique per company.
statusbodyready | review | pending | critDefaults to pending.
batchbodystring, max. 100Batch or lot.
production_datebodyYYYY-MM-DDProduction date, EU data point 9.
on_conflictbodyerror | skipBatch only: skip ignores existing serials instead of failing.
curl -s -X POST https://app.batteriepasswerk.com/api/v1/passes \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: los-2026-09-0042" \
  -d '{
        "items": [
          { "model_id": "f76b5107-…", "serial": "SN-000123", "batch": "B04", "production_date": "2026-09-01" },
          { "model_id": "f76b5107-…", "serial": "SN-000124", "batch": "B04", "production_date": "2026-09-01" }
        ],
        "on_conflict": "skip"
      }'
PATCH/passes/{id}Scope: passes:write

Update a passport

status, batch and production_date can be changed. The legal battery status deliberately cannot be set, it is derived from events.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
curl -s -X PATCH https://app.batteriepasswerk.com/api/v1/passes/ffdad688-… \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "ready" }'

Lifecycle events

GET/passes/{id}/eventsScope: passes:read

Read the event chain

All lifecycle events of a passport, newest first. source is null for dashboard entries and api for machine reports.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
Response
{
  "data": [
    { "id": "9c2e…41a7", "pass_id": "ffdad688-…", "event_type": "market",
      "event_date": "2026-09-05", "note": null, "source": "api",
      "created_at": "2026-09-05T09:12:44.201Z" }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/passes/{id}/eventsScope: passes:write

Report an event

Reports a lifecycle event. The legal battery status is derived from it and returned in the response. recycled and eol retire the passport.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
event_type *bodymarket | repaired | reused | secondlife | remanufactured | recycled | eolKind of event.
event_datebodyYYYY-MM-DDDefaults to today, validated as a calendar date.
notebodystring, max. 500Free text, for example an order or workshop number.
curl -s -X POST https://app.batteriepasswerk.com/api/v1/passes/ffdad688-…/events \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_type": "secondlife", "event_date": "2031-04-18" }'

Telemetry

GET/passes/{id}/telemetryScope: telemetry:read

Read the time series

Measurements of a passport, newest measurement first, ordered by recorded_at.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
Response
{
  "data": [
    { "id": "7a41…c2", "pass_id": "ffdad688-…", "recorded_at": "2026-09-08T22:00:00.000Z",
      "soh_pct": 98.4, "soc_pct": 62, "cycle_count": 142, "negative_event": null,
      "created_at": "2026-09-08T22:00:03.774Z" }
  ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wOS0wOFQyMjowMDowMFoiLCJpIjoiN2E0MSJ9"
}
POST/passes/{id}/telemetryScope: telemetry:write

Report a measurement

At least one measurement or negative_event per call. Requires the Pro plan or higher, independent of the key mode. Out-of-range values return 400.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
recorded_atbodyISO 8601Measurement time from the device, defaults to receive time.
soh_pct, soc_pct, cycle_count, …bodynumberMeasurements, see the telemetry field reference.
negative_eventbodydeep_discharge | overheat | accidentNegative event, EU data point 69.
curl -s -X POST https://app.batteriepasswerk.com/api/v1/passes/ffdad688-…/telemetry \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "recorded_at": "2026-09-08T22:00:00Z",
        "soh_pct": 98.4,
        "soc_pct": 62,
        "cycle_count": 142,
        "temp_min_c": 11.2,
        "temp_max_c": 28.7
      }'

Evidence documents

GET/certificatesScope: certificates:read

List evidence documents

Metadata of the stored evidence documents. validity is derived from valid_until, expiring means it lapses within 30 days.

Parameters

NameInTypeDescription
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
model_idqueryuuidOnly evidence for this model.
typequeryreach | material | duediligence | co2 | conformity | testreport | disassemblyKind of evidence.
statusqueryreview | acceptedApproval state.
Response
{
  "data": [
    { "id": "b2c8…19", "name": "UN 38.3 Testzusammenfassung", "type": "testreport",
      "issuer": "TÜV", "model_id": "f76b5107-…", "valid_until": "2027-05-30",
      "validity": "valid", "status": "accepted", "file_name": "un383.pdf",
      "file_size": 481221, "has_file": true }
  ],
  "has_more": false,
  "next_cursor": null
}
GET/certificates/{id}Scope: certificates:read

Evidence with a download link

Like the list, plus a signed download link valid for 300 seconds. Do not cache the link, fetch it again when needed.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
Response
{
  "id": "b2c8…19",
  "name": "UN 38.3 Testzusammenfassung",
  "type": "testreport",
  "validity": "valid",
  "download_url": "https://…/storage/v1/object/sign/certs/…?token=…",
  "download_expires_at": "2026-09-09T08:36:02.000Z"
}

Suppliers and data requests

GET/suppliersScope: suppliers:read

List suppliers

Every supplier your company has invited at least once, one entry per e-mail address. Newest first.

Parameters

NameInTypeDescription
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
Response
{
  "data": [
    {
      "id": "3f0c…9a",
      "name": "Zellwerk GmbH",
      "email": "einkauf@zellwerk.example",
      "created_at": "2026-09-09T08:12:40.211Z",
      "updated_at": "2026-09-09T08:12:40.211Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
GET/supplier-requestsScope: suppliers:read

List data requests

Every data request sent to a supplier with its status (invited, progress, delivered, expired), the requested fields, due date and reminders. Newest first.

Parameters

NameInTypeDescription
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
supplier_idqueryuuidOnly requests to this supplier.
model_idqueryuuidOnly requests for this model.
statusqueryinvited | progress | delivered | expiredRequest status.
Response
{
  "data": [
    {
      "id": "b7d2…41",
      "supplier_id": "3f0c…9a",
      "supplier_name": "Zellwerk GmbH",
      "supplier_email": "einkauf@zellwerk.example",
      "model_id": "0d1f…c8",
      "model_code": "BESS-10",
      "supplier_type": "cell",
      "status": "delivered",
      "fields": [{ "key": "cobalt_pct", "label": "Kobalt-Anteil", "unit": "%" }],
      "due_date": "2026-10-15",
      "invited_at": "2026-09-09T08:12:41.030Z",
      "expires_at": "2026-11-08T08:12:41.030Z",
      "delivered_at": "2026-09-12T14:03:07.512Z",
      "last_reminder_at": null,
      "reminder_count": 0,
      "created_at": "2026-09-09T08:12:41.030Z",
      "updated_at": "2026-09-12T14:03:07.512Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
POST/supplier-requestsScope: suppliers:write

Invite a supplier

Creates the supplier (matched by e-mail) and a data request for one of your models, then e-mails the password-less self-service link (valid 60 days). The link appears only in this response. Starter plan or higher, at most 60 invitations per hour and company. Dry run validates everything, creates nothing and sends nothing.

Parameters

NameInTypeDescription
supplier_name *bodystringCompany name of the supplier, up to 200 characters.
email *bodye-mailRecipient of the invitation, identifies the supplier within your company.
model_id *bodyuuidOne of your models.
fields *bodyarrayRequested fields { key, label, unit?, label_en?, label_zh? }, 1 to 30. key: a-z, 0-9, underscore.
supplier_typebodycell | material | bmsKind of supplier, optional.
due_datebodyYYYY-MM-DDDue date for the data, optional.
curl -s -X POST https://app.batteriepasswerk.com/api/v1/supplier-requests \
  -H "Authorization: Bearer $BPW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: erp-po-4711-cells" \
  -d '{
        "supplier_name": "Zellwerk GmbH",
        "email": "einkauf@zellwerk.example",
        "model_id": "0d1f…c8",
        "supplier_type": "cell",
        "due_date": "2026-10-15",
        "fields": [
          { "key": "cobalt_pct", "label": "Kobalt-Anteil", "unit": "%", "label_en": "Cobalt share" }
        ]
      }'
GET/supplier-requests/{id}Scope: suppliers:read

Read a data request with delivered values

One request including submissions: the values the supplier delivered per field key, with the file name for document evidence.

Parameters

NameInTypeDescription
id *pathuuidIdentifier of the object inside your company.
Response
{
  "id": "b7d2…41",
  "supplier_name": "Zellwerk GmbH",
  "model_code": "BESS-10",
  "status": "delivered",
  "fields": [{ "key": "cobalt_pct", "label": "Kobalt-Anteil", "unit": "%" }],
  "submissions": [
    {
      "field_key": "cobalt_pct",
      "value": "6.2",
      "file_name": null,
      "file_size": null,
      "submitted_at": "2026-09-12T14:03:07.512Z"
    }
  ]
}

Audit trail

GET/auditScope: audit:read

Read the audit trail

Every write in your company as recorded by the database: who (user, API key or system), what (action such as pass.create, object, reference) and when. seq and row_hash belong to the per-company hash chain and make tampering evident. Newest first.

Parameters

NameInTypeDescription
limitqueryinteger 1-200Page size, default 50.
cursorquerystringOpaque cursor from next_cursor of the previous page.
entity_typequerymodel | pass | cert | supplier | member | api_keyObject type.
actionquerystringExact action, e.g. pass.create, model.update, supplier.invite.
entity_idqueryuuidEntries about this object.
actor_kindqueryuser | api_key | systemWho acted.
actor_key_idqueryuuidEntries written through this API key.
sincequeryISO 8601At or after this timestamp.
untilqueryISO 8601At or before this timestamp.
curl -s "https://app.batteriepasswerk.com/api/v1/audit?entity_type=pass&since=2026-09-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer $BPW_API_KEY"

Model fields

All writable fields of a model in snake_case, identical to the naming in the dashboard. The EU column gives the number of the official EU data point where the field maps to one. Strings are capped at 4000 characters. Read-only: id, readiness, readiness_detail, created_at, updated_at and public_url.

Identity and administration

FieldTypeEU
codestring, Pflicht7
namestring-
categorylmt | bess | ind | ev | device | sli6
statusready | review | pending | crit-
gtinstring1
economic_operator_idstring2
manufacturing_sitestring8
weight_kgnumber10
unitsinteger-
warranty_monthsinteger35
second_lifeboolean-
applicability_flagsobject of boolean-

Conformity

FieldTypeEU
ce_markedboolean40
separate_collectionboolean40
substance_symbolsstring [ ] 41
conformity_responsiblestring2
conformity_declaration_idstring42
due_diligence_urlstring19

Carbon footprint

FieldTypeEU
co2_kg_per_kwhnumber17
carbon_perf_classstring18
co2_phasesobject17
co2_limit_okboolean17
co2_study_urlstring17
lca_method, lca_source_de, lca_source_en, co2_auditorstring-

Materials

FieldTypeEU
chemistrystring12
hazard_de, hazard_en, hazard_detailstring13
substance_impact_de, substance_impact_enstring13
critical_materialsarray15
cathode, anode_de, anode_en, electrolytestring45
active_materials, material_originstring-

Circularity

FieldTypeEU
rec_cobalt_pct, rec_lithium_pct, rec_nickel_pct, rec_lead_pctnumber20-23
renewable_pctnumber24
recyclate_pctinteger-
eol_info_de, eol_info_enstring43
waste_prevention_url, separate_collection_url, collection_info_urlstring43
recycling_efficiency_pctnumber-
spare_part_numbersstring46
spare_source_postal, spare_source_email, spare_source_webstring47
disassembly_doc_urlstring48
disassembly_cert_iduuid48
safety_measures_urlstring49

Performance and durability

FieldTypeEU
energy_kwhnumber11
nominal_voltage_vnumber27
voltage_min_v, voltage_max_vnumber26, 28
rated_capacity_ahnumber25
power_wnumber29
max_power_wnumber30
rated_cyclesinteger31, 59
cycle_life_teststring32
capacity_threshold_pctnumber33
temp_min_c, temp_max_cnumber34
temp_storage_min_c, temp_storage_max_cnumber34
round_trip_pctnumber36
rte_50_pctnumber37
internal_resistance_cell_mohm, internal_resistance_pack_mohmnumber38
c_ratenumber39
capacity_fade_pct, power_fade_pct, rte_fade_pctnumber52, 54, 58
expected_lifetime_yearsnumber60
hazard_classstring-
extinguishing_de, extinguishing_enstring14

Passport fields

Writable on creation: model_id, serial, status, batch and production_date; afterwards status, batch and production_date via PATCH. Everything else belongs to the server.

FieldMeaning
lifecycle_statusLegal battery status, derived from the event chain (EU 67).
retired_atSet as soon as recycled or eol was reported.
state_of_health_pctLatest reported telemetry value.
short_code, short_urlShort link of the public passport page.
public_url, gs1_linkGS1 Digital Link once the model has a GTIN, otherwise the identifier address.
registry_status, registry_uri, registry_registered_at, registry_errorState of the registration in the EU registry.
supersedes_pass_id, superseded_by_pass_idChaining on repurposing and remanufacturing.

Event types and the status they produce

event_typelifecycle_status afterMeaning
market-Placed on the market. Does not change the status but is the legal starting point.
repaired-Repaired, the status stays unchanged.
reusedre-usedReuse for the original purpose.
secondliferepurposedRepurposing, for example a vehicle battery becomes stationary storage.
remanufacturedremanufacturedRemanufactured.
recycledwasteRecycled. Retires the passport, retired_at is set.
eolwasteEnd of life without proof of recycling.

Telemetry fields

At least one measurement or negative_event per call. Values outside the range return 400 with out_of_range and the offending field in param.

FieldRangeEU
recorded_atISO 8601-
soh_pct0-100-
soc_pct0-10071
soce_pct0-10061
capacity_kwh≥ 051
power_kw≥ 053
remaining_capacity_ah≥ 062
remaining_power_capability_pct0-10063
remaining_rte_pct0-10064
self_discharge_pct_month≥ 065
ohmic_resistance_mohm≥ 066
internal_resistance_increase_pct≥ 056
cycle_count≥ 0, integer68
negative_eventdeep_discharge | overheat | accident69
temp_min_c, temp_max_c≥ -27370
time_extreme_high_min, time_extreme_low_min, time_charging_extreme_high_min, time_charging_extreme_low_min≥ 070
energy_throughput_kwh, capacity_throughput_ah≥ 0-
notestring, max. 500-

Webhooks

The API is pull, webhooks are push. Signed events reach your system the moment they happen: pass.created, model.updated, supplier.delivered and cert.expiring. Signed with HMAC-SHA256, retried on failure. Proven pattern: take the webhook as the trigger and then load the affected resource through the API, so the truth always comes from the API. Calls through the API fire the same webhooks as dashboard entries.

Versioning and changelog

The version lives in the path. Within v1 only fields and endpoints are added; existing fields, error codes and semantics stay. If anything is removed we announce it at least twelve months ahead here, in the OpenAPI document and by email to the admins, and run the successor in parallel under /v2.

VersionDateChange
1.1.02026-09-09New scopes telemetry:read, suppliers:read, suppliers:write and audit:read. New endpoints GET /suppliers, GET and POST /supplier-requests, GET /supplier-requests/{id}, GET /audit. Existing keys with passes:read were extended with telemetry:read automatically.
1.0.02026-09-09First release: models, passports, events, telemetry, evidence documents, field catalog, test keys with dry run, idempotency key, cursor pagination.

Frequently asked questions

Is the REST API available today?
Yes. Version 1.1.0 is in production: models, passports, lifecycle events, telemetry, evidence documents, supplier requests, the audit trail and the field catalog. The full interface for ERP and MES belongs to the Enterprise plan, the BMS telemetry interface is included from Pro, and a fully functional test key is available in every plan, including the free Pilot.
How do I test without creating real passports?
With a test key. It validates every request in full against your real master data, scopes, mandatory fields and quotas and returns the result a live call would produce, without storing anything. There is deliberately no separate sandbox database of invented data that drifts away from reality over time.
Who is allowed to create an API key?
Only accounts with the admin role, which always includes the owner. The rule is enforced on three levels: in the database, in the endpoint and in the interface. Every action on a key is recorded in the audit trail with person and timestamp.
Which programming language do I need?
Any language that speaks HTTPS and JSON. There is no SDK you have to adopt. From the OpenAPI 3.1 document, common generators will produce a typed client for Java, C#, Python, TypeScript or Go if you want one.
How do I find the passport id for a serial number?
Through GET /passes with the serial parameter. Serial numbers are unique per company, so there is at most one hit. Many integrations resolve the id once and store it next to the serial in their own system.
What happens if a request times out?
Retry it with the same Idempotency-Key. If the first request was processed you receive the stored response instead of a duplicate. If the payload differs under the same key, the API rejects the request rather than quietly doing something else.
Can I delete a passport through the API?
No, by design. Battery passports are subject to retention obligations, so version 1 has no delete operation. The end of life is reported as an event, for example recycling, after which the passport shows its final state.
How many passports can I create?
The quota depends on the plan and is reported in every GET /me response under usage. The enforcement field says what happens when you exceed it: hard means rejection, overage means billing per additional passport, contract means an agreed volume without a block.

Questions about your integration?

In an intro call we clarify which data comes from which system, which scopes your keys need and how serialisation fits your production line.

Updated 9 September 2026 · API version 1.1.0 · Not legal advice · The regulation text is binding