# 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.
