Skip to content

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

OperationsMethodSafe to repeatWhy
All reads, including public passport readsGETYesThey change nothing.
updateConditionPATCHYes, with the same measured_atA measurement equal to or older than the stored one is skipped and listed.
createLifecycleEventPOSTNoEvery 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:

Request
{
  "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:

200
{
  "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:

  1. Read listLifecycleEvents for the unit (newest first).
  2. Look for an event with source: "api" and your type, occurred_on and description.
  3. 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.

On this page