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

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. 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_log entry tagged source=bms-webhook
  • Stamps lastUsedAt on the secret used
  • Bumps the product's updatedAt; the public DPP picks up the new value within โ‰ค 60 s (cache TTL)

Path parametersโ€‹

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

Headersโ€‹

HeaderRequiredDescription
Content-TypeYesMust be application/json.
X-Traceable-BMS-SecretYesPlaintext per-company secret provisioned by Traceable. The server stores only SHA-256(secret) โ€” the plaintext is never persisted.
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
}
}

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โ€‹

StatuscodeWhen
400BAD_BODYBody was not valid JSON.
401MISSING_SECRETX-Traceable-BMS-Secret header was absent.
401BAD_SECRETThe presented secret does not match any active, non-revoked record.
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.
404NOT_FOUNDNo product exists with the given UUID.
500INTERNALDatabase write failed. The full transaction is rolled back; safe to retry.
503FEATURE_FLAG_OFFBMS 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()
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 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:

  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โ€‹

Multiple secrets may be active per company simultaneously (rotation-friendly). To rotate:

  1. Contact support@traceable.digital to generate a new secret for your company.
  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), 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โ€‹

  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. 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.
  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 within 60 s.