Retries and idempotency
Which requests you can safely repeat after a timeout, and how to avoid duplicate events.
When a request times out or the connection drops, you do not know whether BatteryPass carried it out. Whether you can simply send it again depends on the operation.
Overview
| Operations | Method | Safe to repeat | Why |
|---|---|---|---|
| All reads, including public passport reads | GET | Yes | They change nothing. |
updateCondition | PATCH | Yes, with the same measured_at | A measurement equal to or older than the stored one is skipped and listed. |
createLifecycleEvent | POST | No | Every successful call records a new event. |
A request answered with 429 was not carried out and can always be repeated after Retry-After (see
Rate limits). A 4xx answer means the request was refused as a whole: fix it instead
of repeating it.
Reads
Every GET of the API is safe to repeat as often as you like. Retry network errors and 5xx responses with
exponential backoff.
Condition data
updateCondition merges measured values into the unit's condition data. For each value, BatteryPass compares
its measured_at with that of the stored value:
- newer than the stored measurement, or the same time with a different value: stored, listed in
accepted; - the same time and the same value: skipped with the reason
unchanged; - older than the stored measurement: skipped with the reason
older_measurement.
So a repeated request with the same body changes nothing. Send it, for example:
{
"measured_at": "2026-10-24T02:00:00Z",
"values": [
{ "field_key": "condition.state_of_charge", "value": 87.5 },
{ "field_key": "condition.number_of_full_cycles", "value": 412 }
]
}If the first attempt was stored, the repetition answers:
{
"accepted": [],
"skipped": [
{ "field_key": "condition.state_of_charge", "reason": "unchanged" },
{ "field_key": "condition.number_of_full_cycles", "reason": "unchanged" }
],
"publication": { "outcome": "not_published" }
}No new lifecycle event is recorded and nothing is published by this request: not_published here means that
this request published nothing, and the passport keeps its current version.
Set measured_at from the measurement, for example the time your battery management system took the
reading, and keep it when you repeat the request. A measured_at taken from the clock at sending time turns
every repetition into a new measurement.
Lifecycle events
createLifecycleEvent has no deduplication: each successful call records a new event, and the published API
has no idempotency key. After a timeout, a dropped connection or a 5xx answer, check whether the event
arrived before you send it again:
- Read
listLifecycleEventsfor the unit (newest first). - Look for an event with
source: "api"and yourtype,occurred_onanddescription. - Send the event again only if it is not there.
Put a reference of your own into description, such as the service order number, so the event is easy to
recognise. BatteryPass stores the description without leading and trailing spaces.
type NewEvent = { type: string; occurred_on: string; description: string };
type LifecycleEvent = NewEvent & { id: string; source: string };
const base = `${process.env.BATTERYPASS_URL}/api/v1`;
const headers = {
Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}`,
'Content-Type': 'application/json',
};
// Looks for an event this integration already recorded (same type, day and description).
async function findEvent(batteryId: string, e: NewEvent): Promise<LifecycleEvent | undefined> {
let cursor: string | null = null;
do {
const query = new URLSearchParams({ limit: '200' });
if (cursor) query.set('cursor', cursor);
const res = await fetch(`${base}/batteries/${batteryId}/lifecycle-events?${query}`, { headers });
if (!res.ok) throw new Error(`listLifecycleEvents: ${res.status}`);
const page: { items: LifecycleEvent[]; next_cursor: string | null } = await res.json();
const hit = page.items.find(
(x) => x.source === 'api' && x.type === e.type && x.occurred_on === e.occurred_on && x.description === e.description,
);
if (hit) return hit;
// Newest first: once a page reaches older days, the event is not there.
if (page.items.some((x) => x.occurred_on < e.occurred_on)) return undefined;
cursor = page.next_cursor;
} while (cursor);
return undefined;
}
async function recordEvent(batteryId: string, e: NewEvent): Promise<LifecycleEvent> {
for (let attempt = 1; attempt <= 3; attempt++) {
let res: Response | undefined;
try {
res = await fetch(`${base}/batteries/${batteryId}/lifecycle-events`, {
method: 'POST',
headers,
body: JSON.stringify(e),
signal: AbortSignal.timeout(30_000),
});
} catch {
res = undefined; // timeout or connection error
}
if (res?.status === 201) return res.json();
if (res && res.status < 500) throw new Error(`createLifecycleEvent: ${res.status} ${await res.text()}`);
// No answer or a 5xx: the event may have been recorded anyway.
const existing = await findEvent(batteryId, e);
if (existing) return existing;
}
throw new Error('createLifecycleEvent: no success after 3 attempts');
}
const event = await recordEvent('0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12', {
type: 'inspection',
occurred_on: '2026-10-24',
description: 'Service order SO-2026-0815: annual inspection',
});
console.log(event.id);The samples treat a 429 like any other refusal; combine them with the back-off from
Rate limits in production.
The Idempotency-Key header
BatteryPass knows an Idempotency-Key header, but only on portal operations such as publishing and bulk jobs.
None of the published operations accepts it, so do not send it to the API: it has no effect there.