BMS Endpoints
Battery management systems push lifetime telemetry into the Digital Product Passport via a single, HMAC-signed inbound API endpoint. Each manufacturer holds a per-company secret (rotation-capable) and signs every request body. 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.
The BMS rail is governed by NEXT_PUBLIC_BMS_INGESTION_ENABLED. While the flag is off, the endpoint returns 503 FEATURE_FLAG_OFF. Contact your Traceable account contact to enable it for your tenant.
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. Updates merge into formData and upsert the dedicated BatteryLifetimeCounter row. Each successful call:
- Merges whitelisted fields into the product's
formData(existing keys are preserved unless overwritten by the patch โ keys outside the whitelist are silently rejected) - Upserts the seven Annex XIII counter fields on
battery_lifetime_counter - Writes one
audit_logentry taggedsource=bms-webhook - Stamps
lastUsedAton the secret used - Bumps the product's
updatedAt; the public DPP picks up the new value within โค 60 s (cache TTL)
Path parametersโ
| Name | Type | Description |
|---|---|---|
id | UUID | The internal product ID. Returned by GET /api/dpp/{slug} as data.id, or visible in the operator portal URL. |
Headersโ
| Header | Required | Description |
|---|---|---|
Content-Type | Yes | Must be application/json. |
X-Traceable-BMS-Secret | Yes | Plaintext per-company secret provisioned by Traceable. The server stores only SHA-256(secret) โ the plaintext is never persisted. |
X-Traceable-Signature | Yes | t=<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
}
}
Both top-level keys are optional, but the request must include at least one. null is allowed for any counter (signals "not yet measured"); omitting a key leaves the existing value in place.
Whitelisted formDataPatch keysโ
The endpoint silently drops any key outside this set and returns the rejected list in the response. 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_kw
remaining_round_trip_efficiency_pct
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
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
}
rejectedKeys lists any payload keys dropped because they are not in the whitelist; treat it as a client-side configuration warning.
Errorsโ
| Status | code | When |
|---|---|---|
400 | BAD_BODY | Body was not valid JSON. |
401 | MISSING_SECRET | X-Traceable-BMS-Secret header was absent. |
401 | BAD_SECRET | The presented secret does not match any active, non-revoked record. |
401 | BAD_SIGNATURE | HMAC 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. |
403 | FORBIDDEN | Secret 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. |
404 | NOT_FOUND | No product exists with the given UUID. |
500 | INTERNAL | Database write failed. The full transaction is rolled back; safe to retry. |
503 | FEATURE_FLAG_OFF | BMS ingestion is not enabled for this environment. Contact your Traceable account contact. |
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()
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 never leaves your secret store. The platform only ever stores
SHA-256(secret)and matches incoming requests against that hash. - 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:
- The product exists.
- The product's
companyIdequals the secret'scompanyId. - If
productIdson the secret is non-empty, it must include the product.
Rotationโ
Multiple secrets may be active per company simultaneously (rotation-friendly). To rotate:
- Contact support@traceable.digital to generate a new secret for your company.
- Roll your BMS provider over to the new secret on their end.
- Once you've confirmed traffic on the new secret (
lastUsedAtupdates), request revocation of the old one. Revoked secrets reject immediately.
Audit trailโ
Every 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 is subject to the platform's middleware rate limits (per-IP and per-secret). Sustained traffic above the published per-second budget will return 429; back-off-and-retry with jitter is the recommended client behaviour. See Rate Limiting for thresholds.
Public DPP propagationโ
Updated values appear on the public DPP within โค 60 s of a successful POST (cache TTL on the catalog scope). The public DPP renders force-dynamic, so the read path itself is live; only the catalog cache is bounded.
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 (โค 60 s).
- 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โ
- Email support@traceable.digital with your company name and the product UUIDs you intend to wire to a BMS.
- Traceable confirms
NEXT_PUBLIC_BMS_INGESTION_ENABLED=truein your environment. - Contact support@traceable.digital to provision a BMS secret for your company (label it per provider โ e.g. "BMS provider A โ prod key"). The plaintext secret is shared with you once; it is never stored on the platform side.
- Share the plaintext secret with your BMS provider through your secrets pipeline.
- Send a smoke-test POST against staging first; you should see
lastUsedAtpopulate on the secret and the updated value appear on the public DPP within 60 s.