Skip to main content

BMS Endpoints

Battery management systems push lifetime telemetry into the Digital Product Passport via a single, HMAC-signed inbound API endpoint. Each product has its own secret, issued by a company user in the Company Portal (rotation-capable), and every request body is signed. The endpoint is whitelist-only: it can write lifetime / dynamic fields (cycle count, state of health, time-at-temperature counters, energy throughput, deep-discharge events) but cannot mutate static identity, certifications, or compliance data.

BMS ingestion is not switched on in the production platform at present. Until Traceable enables it, the endpoint returns 503 FEATURE_FLAG_OFF. The BMS rail is governed by NEXT_PUBLIC_BMS_INGESTION_ENABLED. Contact your Traceable account contact to ask for it to be enabled.

Machine-readable spec

The canonical OpenAPI document for this endpoint is bms-v1.yaml. It is the source of truth; if anything on this page disagrees with it, the spec wins.


POST /api/v1/products/{id}/bms-update​

Append a telemetry update to a single product. A model-level update (a body without unit) merges into formData and upserts the dedicated BatteryLifetimeCounter row. A unit update (a body with unit) writes one history row for that unit and does not touch products.form_data or battery_lifetime_counter. Each successful model-level call:

  • Merges whitelisted fields into the product's formData (existing keys are preserved unless overwritten by the patch: keys outside the whitelist are dropped and reported in rejectedKeys)
  • Upserts the seven lifetime counter fields on battery_lifetime_counter (time in four temperature bands, energy throughput in kWh, charge throughput in Ah, and deep-discharge events). Annex XIII does not define these seven fields; they record usage data of the kind Annex XIII point 4(d) describes
  • Writes one audit_log entry tagged source=bms-webhook
  • Stamps lastUsedAt on the secret used
  • Bumps the product's updatedAt. The public DPP shows the new value once its CDN copy refreshes; see Public DPP propagation

Path parameters​

NameTypeDescription
idUUIDThe internal product ID. Returned by GET /api/dpp/{slug} as productId, or visible in the operator portal URL.

Headers​

HeaderRequiredDescription
Content-TypeYesMust be application/json.
X-Traceable-BMS-SecretYes, unless X-Traceable-BMS-Kid is usedPlaintext per-product secret, issued in the Company Portal (see Rotation). The platform stores the SHA-256 hash of the secret and, for key-ID authentication, an encrypted copy in a vault.
X-Traceable-BMS-KidNoThe Key ID shown when the secret was issued. It replaces X-Traceable-BMS-Secret and is honoured only on a platform where key-ID authentication is enabled; otherwise the header is ignored. You still sign with the plaintext secret.
X-Traceable-SignatureYest=<unix_seconds>, v1=<hex_hmac_sha256> over the string ${timestamp}.${rawBody} using the plaintext secret as the HMAC key. The signed timestamp must be within the 5-minute replay window.

Request body​

{
"formDataPatch": {
"current_soh_pct": 96.4,
"current_cycle_count": 312,
"remaining_capacity_kwh": 60.1,
"remaining_round_trip_efficiency_pct": 94.7,
"self_discharge_rate_evolution_pct_per_month": 1.5,
"soh_last_updated": "2026-04-28T08:30:00Z"
},
"lifetimeCounters": {
"timeAtTempExtremeLowSec": 0,
"timeAtTempLowSec": 1240,
"timeAtTempHighSec": 980,
"timeAtTempExtremeHighSec": 0,
"totalEnergyThroughputKwh": 1875.3,
"totalEnergyThroughputAh": 2253.6,
"deepDischargeEvents": 0
}
}

The body schema is strict. All keys are optional. Unknown top-level keys are rejected with 400 VALIDATION. The body may not exceed 16 KB (413 PAYLOAD_TOO_LARGE). null is allowed for any counter (signals "not yet measured"); omitting a key leaves the existing value in place.

KeyDescription
unit{ "serialNumber": "..." }. Addresses one unit of the product. When present, clientEventId and measuredAt are required.
clientEventIdIdempotency id, 1 to 64 characters from A-Z a-z 0-9 _ -. Repeating it with the same body returns the stored result; repeating it with a different body returns 409 IDEMPOTENCY_CONFLICT.
measuredAtISO 8601 timestamp with an offset. It may be at most 5 minutes in the future and at most 30 days old (422 MEASURED_AT_IN_FUTURE, 422 MEASURED_AT_TOO_OLD).
bmsResetBoolean, unit updates only. Send true after the BMS counters were reset, so a lower counter value is not refused as COUNTER_REGRESSION.
readingsPoint-in-time readings: socPct (0 to 100), sohPct (0 to 120), operatingEnvironmentTemperatureC (-60 to 120), cycleCount, timeAtExtremeTempSec, timeChargingExtremeTempSec, negativeEvents[] (up to 50), batteryStatus and performanceDurabilitySnapshot. Unknown keys inside are rejected.
lifetimeCountersThe seven counters shown above. Values must be zero or greater.
formDataPatchWhitelisted keys only, see below.

Whitelisted formDataPatch keys​

The endpoint drops any key outside this set and returns the dropped keys in rejectedKeys. The value of an allowed key is type-checked: a wrong type or an out-of-range value returns 400 VALIDATION. A compromised BMS secret cannot rewrite manufacturer name, GTIN, certifications, or carbon-footprint disclosures.

current_soh_pct
soh_last_updated
current_cycle_count
cycle_count_last_updated
remaining_energy_kwh
remaining_capacity_ah
remaining_capacity_kwh
remaining_power_capability_w
remaining_round_trip_efficiency_pct
soh_ohmic_resistance_mohm
self_discharge_rate_evolution_pct_per_month
total_energy_throughput_kwh
total_energy_throughput_ah
deep_discharge_events
overcharge_events
number_of_charging_events
time_at_temp_extreme_low_sec
time_at_temp_low_sec
time_at_temp_high_sec
time_at_temp_extreme_high_sec
time_charging_at_temp_extreme_high_sec
time_charging_at_temp_extreme_low_sec
lifetime_counter_last_updated

soh_ohmic_resistance_mohm is the ohmic resistance in milliohms and must not be negative. remaining_power_capability_w is in watts. The earlier remaining_power_capability_kw key is no longer accepted and comes back in rejectedKeys.

Response: 200 OK​

{
"ok": true,
"productId": "0658ffd2-fca2-4b71-9638-97d4badd3a51",
"appliedKeys": [
"current_soh_pct",
"current_cycle_count",
"remaining_capacity_kwh",
"remaining_round_trip_efficiency_pct",
"self_discharge_rate_evolution_pct_per_month",
"soh_last_updated"
],
"rejectedKeys": [],
"counterUpdated": true,
"updateId": "5b0c1f0e-6f0c-4c53-a9a8-2f0f7c1d9a11",
"duplicate": false
}

rejectedKeys lists any payload keys dropped because they are not in the whitelist; treat it as a client-side configuration warning. updateId is the id of the history row written for this call. A unit update answers with unit: { serialNumber } in place of counterUpdated. Repeating a clientEventId with the same body returns { "ok": true, "productId": "...", "updateId": "...", "duplicate": true }.

Errors​

BMS errors use a flat body, { "error": "<message>", "code": "<CODE>" }, not the nested envelope of the rest of /api/v1.

StatuscodeWhen
400BAD_BODYBody was not valid JSON.
400VALIDATIONThe body failed the strict schema, or an allowed formDataPatch key has a wrong type or range. The response carries issues.
401MISSING_SECRETX-Traceable-BMS-Secret header was absent.
401BAD_SECRETThe presented secret does not match any active, non-revoked record.
401BAD_KID, KID_NO_VAULT, VAULT_MISSKey-ID authentication only: a malformed or unknown Key ID, a secret that does not support key-ID authentication, or the stored secret could not be found.
401BAD_SIGNATUREHMAC signature missing, malformed, expired beyond the 5-min replay window, or fails timing-safe comparison. The body includes a non-sensitive reason to help client debugging.
403FORBIDDENSecret is valid but does not belong to the company that owns this product, or the secret is product-scoped to a set that excludes this product.
403COMPANY_INACTIVEThe owning company is suspended or inactive.
404NOT_FOUNDNo product exists with the given UUID.
404UNIT_NOT_FOUNDThe unit.serialNumber is not a unit of this product.
409IDEMPOTENCY_CONFLICTclientEventId was already used with a different body.
409OUT_OF_ORDERUnit update: measuredAt is earlier than the unit's latest reading. The response carries latestMeasuredAt; send readings oldest first.
409COUNTER_REGRESSIONUnit update: a counter is lower than its earlier value. The response lists the counters. Send bmsReset: true if the BMS counters were reset.
410PRODUCT_FROZENThe product no longer accepts new data: it is Revoked, or Withdrawn as counterfeit, Withdrawn with no recorded reason, or Withdrawn under a regulatory directive that orders a freeze (or that did not record whether it does). A Deprecated product and a product withdrawn for any other reason still accept telemetry. The public passport of a withdrawn product is served as it was published.
413PAYLOAD_TOO_LARGEThe body is larger than 16 KB.
422MEASURED_AT_IN_FUTURE, MEASURED_AT_TOO_OLDmeasuredAt is more than 5 minutes ahead of the server clock, or more than 30 days old.
429RATE_LIMITED_IP, RATE_LIMITED_SECRETSee Rate limiting.
500INTERNALDatabase write failed. The full transaction is rolled back; safe to retry.
503RATE_LIMITER_UNAVAILABLEBMS ingestion is unavailable because its rate limiter cannot be reached. Retry later.
503FEATURE_FLAG_OFFBMS ingestion is not enabled for this environment. Contact your Traceable account contact.
503SCHEMA_UNAVAILABLEThe BMS history store is not available on this environment.

GET /api/dpp/{slug}/units/{serial}/bms-history​

Reads the BMS history of one battery unit. Under Annex XIII point 4 of Regulation (EU) 2023/1542 this data is available only to persons with a legitimate interest, so the endpoint answers two kinds of reader and no one else. It is not part of the public passport, the JSON-LD or the public JSON.

ReaderHow they identify
A person with an approved legitimate-interest access request for this productAuthorization: Bearer <access token>. The token is the one in the access link emailed on approval. See PoLI access
A signed-in member of the company that owns the product, in any team roleTheir session

A request with an Authorization header is judged on that header alone. The query string and the poli_access_token cookie are never read here. A token for an authority request does not work: only the legitimate-interest tier can read unit history. A token that has expired, or belongs to another product, is refused.

Path parameters​

ParameterDescription
slugThe product's public URL slug
serialThe unit's serial number

Response: 200 OK​

{
"serialNumber": "SN-0001",
"truncated": false,
"updates": [
{
"measuredAt": "2026-10-01T08:00:00.000Z",
"receivedAt": "2026-10-01T08:00:02.000Z",
"socPct": 82,
"sohPct": 97.5,
"cycleCount": 214,
"bmsReset": false,
"otherValues": {}
}
]
}

Each entry is one update, newest first, and carries only the fields the BMS rail accepts: measuredAt, receivedAt, socPct, sohPct, operatingEnvironmentTemperatureC, cycleCount, the timeAtTemp…Sec counters, totalEnergyThroughputKwh, totalEnergyThroughputAh, deepDischargeEvents, timeAtExtremeTempSec, timeChargingExtremeTempSec, negativeEvents, batteryStatus, performanceDurabilitySnapshot, bmsReset and otherValues. A field the update did not report is null. The example above is shortened. Up to 500 entries are returned; when there are more, truncated is true.

Errors​

StatusCodeWhen
404NOT_FOUNDThe slug or serial is unknown or malformed (a slug over 200 characters, a serial over 128 characters or with control characters), or you may not read this unit's history. All three look the same, so the endpoint does not reveal which serials exist
429RATE_LIMITEDMore than 30 requests a minute from one IP address. Wait for the Retry-After time (60 seconds)
500INTERNAL_ERRORThe history could not be read
503SCHEMA_UNAVAILABLEBMS history is not yet available in this environment

Every response carries Cache-Control: private, no-store. Each history read that is served is recorded in the product's audit trail. If that record cannot be written, the data is not served.


Request signing: reference implementations​

curl​

PRODUCT_ID="0658ffd2-fca2-4b71-9638-97d4badd3a51"
SECRET="your-plaintext-secret"

BODY='{"lifetimeCounters":{"totalEnergyThroughputKwh":1875.3,"deepDischargeEvents":0}}'
TS=$(date -u +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -X POST "https://app.traceable.digital/api/v1/products/$PRODUCT_ID/bms-update" \
-H "Content-Type: application/json" \
-H "X-Traceable-BMS-Secret: $SECRET" \
-H "X-Traceable-Signature: t=$TS, v1=$SIG" \
--data "$BODY"

Node.js​

import crypto from "node:crypto";

const productId = "0658ffd2-fca2-4b71-9638-97d4badd3a51";
const secret = process.env.TRACEABLE_BMS_SECRET!; // never log or commit

const body = JSON.stringify({
formDataPatch: {
current_soh_pct: 96.4,
current_cycle_count: 312,
},
lifetimeCounters: {
totalEnergyThroughputKwh: 1875.3,
deepDischargeEvents: 0,
},
});

const ts = Math.floor(Date.now() / 1000);
const sig = crypto
.createHmac("sha256", secret)
.update(`${ts}.${body}`)
.digest("hex");

const res = await fetch(
`https://app.traceable.digital/api/v1/products/${productId}/bms-update`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Traceable-BMS-Secret": secret,
"X-Traceable-Signature": `t=${ts}, v1=${sig}`,
},
body,
},
);

if (!res.ok) {
const err = await res.json();
throw new Error(`BMS update failed: ${err.code}: ${err.error}`);
}

Python​

import hashlib
import hmac
import json
import os
import time

import httpx

PRODUCT_ID = "0658ffd2-fca2-4b71-9638-97d4badd3a51"
secret = os.environ["TRACEABLE_BMS_SECRET"]

body = json.dumps({
"lifetimeCounters": {
"totalEnergyThroughputKwh": 1875.3,
"deepDischargeEvents": 0,
}
}, separators=(",", ":"))

ts = int(time.time())
sig = hmac.new(
secret.encode(),
f"{ts}.{body}".encode(),
hashlib.sha256,
).hexdigest()

response = httpx.post(
f"https://app.traceable.digital/api/v1/products/{PRODUCT_ID}/bms-update",
headers={
"Content-Type": "application/json",
"X-Traceable-BMS-Secret": secret,
"X-Traceable-Signature": f"t={ts}, v1={sig}",
},
content=body,
)
response.raise_for_status()
Body byte-stability matters

The signature is computed over the exact bytes you POST: re-serializing the same object client-side and server-side will produce different signatures. Build the body string once and use the same string for both the HMAC and the request body.


Operational guarantees​

Authentication​

  • The plaintext secret is shown once, when it is issued. The platform matches incoming requests against the stored SHA-256 hash and, for key-ID authentication, keeps an encrypted copy of the secret in a vault.
  • HMAC verification uses a constant-time comparison (crypto.timingSafeEqual).
  • The 5-minute replay window enforces freshness: a captured request older than 5 minutes is rejected even with a valid signature.

Authorization​

A secret is bound to a single company and may optionally be product-scoped. The endpoint enforces:

  1. The product exists.
  2. The product's companyId equals the secret's companyId.
  3. If productIds on the secret is non-empty, it must include the product.

Rotation​

A company user issues and revokes BMS secrets in the BMS webhook secrets card on the product's Units page in the Company Portal (/company-portal/products/{productId}/units), after a step-up confirmation. The secret is shown once, together with its Key ID. A secret issued there is scoped to that product, and at most two secrets can be active per product, so rotation has room for an overlap. To rotate:

  1. Open the product's Units page and issue a new secret in the BMS webhook secrets card.
  2. Roll your BMS provider over to the new secret on their end.
  3. Once you've confirmed traffic on the new secret (lastUsedAt updates), revoke the old one in the same card. Revoked secrets reject immediately.

Audit trail​

A successful update writes an audit_log entry:

{
"eventType": "Update",
"actorRole": "System",
"actorId": "<webhook_secret_id>",
"eventChannel": "API",
"eventContext": {
"source": "bms-webhook",
"appliedFormDataKeys": ["current_soh_pct", "current_cycle_count"],
"rejectedFormDataKeys": [],
"counterUpdated": true,
"webhookSecretId": "<webhook_secret_id>"
}
}

Visible to the operator on the Audit Trail page and to verifiers / market-surveillance authorities via the PoLI access path.

Rate limiting​

The BMS endpoint allows 600 requests per 60 seconds per IP (sliding window), checked before authentication, and 60 requests per 60 seconds per secret. Exceeding them returns 429 RATE_LIMITED_IP or 429 RATE_LIMITED_SECRET. The 120 per minute per-IP edge limit that applies to every /api path also applies here. The limiters fail closed: if they cannot be reached, the request is refused with 503 RATE_LIMITER_UNAVAILABLE instead of being served unchecked. Back-off-and-retry with jitter is the recommended client behaviour. See Rate Limiting for the other layers.

Public DPP propagation​

The BMS route does not revalidate any cache. Updated values appear on GET /api/dpp/{slug} after the CDN copy refreshes: that response is served with Cache-Control: public, s-maxage=300, stale-while-revalidate=3600. Do not rely on a 60 second bound.


What this endpoint will not do​

The BMS rail is intentionally narrow.

  • It does not mutate static identity (manufacturer_name, model_number, gtin, serial_number, battery_passport_urn).
  • It does not mutate certifications, CE markings, or test reports.
  • It does not mutate carbon footprint or recycled content disclosures.
  • It does not push to viewers in real-time: clients see the new value on their next page load after the CDN copy refreshes (see Public DPP propagation).
  • It does not pull from your BMS: you POST to us; we never request your credentials.
  • It does not chain telemetry updates cryptographically; each update is HMAC-authenticated at the wire only. Per-update hash chaining is on the roadmap.

If you need a mutation outside the whitelist, use the operator portal wizard (which captures the change with full audit context and a dppVersion bump) or the Supplier Portal data request flow.


Enabling BMS ingestion for your tenant​

  1. Email support@traceable.digital with your company name and the product UUIDs you intend to wire to a BMS.
  2. Traceable confirms NEXT_PUBLIC_BMS_INGESTION_ENABLED=true in your environment.
  3. In the Company Portal, open the product's Units page and issue a secret in the BMS webhook secrets card (label it per provider, for example "BMS provider A - prod key"). The plaintext secret is shown once, with its Key ID.
  4. Share the plaintext secret with your BMS provider through your secrets pipeline.
  5. Send a smoke-test POST against staging first; you should see lastUsedAt populate on the secret, and the updated value appear on the public DPP once its CDN copy refreshes.
Was this page helpful?