# 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`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/update-condition)            | `PATCH` | Yes, with the same `measured_at` | A measurement equal to or older than the stored one is skipped and listed. |
| [`createLifecycleEvent`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/create-lifecycle-event) | `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](https://passport.kabin.sh/docs/en/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:

```json title="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:

```json title="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.

> [!WARNING]
> 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`](https://passport.kabin.sh/docs/en/api-reference/lifecycle/list-lifecycle-events) 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.

```ts title="TypeScript"
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);
```

```python title="Python"
import os

import requests

base = f"{os.environ['BATTERYPASS_URL']}/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['BATTERYPASS_API_KEY']}"


def find_event(battery_id, event):
    """Looks for an event this integration already recorded (same type, day and description)."""
    params = {"limit": 200}
    while True:
        res = session.get(f"{base}/batteries/{battery_id}/lifecycle-events", params=params, timeout=30)
        res.raise_for_status()
        page = res.json()
        for item in page["items"]:
            if (item["source"] == "api" and item["type"] == event["type"]
                    and item["occurred_on"] == event["occurred_on"]
                    and item.get("description") == event["description"]):
                return item
        # Newest first: once a page reaches older days, the event is not there.
        if any(item["occurred_on"] < event["occurred_on"] for item in page["items"]) or not page["next_cursor"]:
            return None
        params["cursor"] = page["next_cursor"]


def record_event(battery_id, event, attempts=3):
    for _ in range(attempts):
        try:
            res = session.post(f"{base}/batteries/{battery_id}/lifecycle-events", json=event, timeout=30)
        except (requests.Timeout, requests.ConnectionError):
            res = None  # timeout or connection error
        if res is not None and res.status_code == 201:
            return res.json()
        if res is not None and res.status_code < 500:
            raise RuntimeError(f"createLifecycleEvent: {res.status_code} {res.text}")
        # No answer or a 5xx: the event may have been recorded anyway.
        existing = find_event(battery_id, event)
        if existing:
            return existing
    raise RuntimeError(f"createLifecycleEvent: no success after {attempts} attempts")


event = record_event("0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", {
    "type": "inspection",
    "occurred_on": "2026-10-24",
    "description": "Service order SO-2026-0815: annual inspection",
})
print(event["id"])
```

```go title="Go"
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

type newEvent struct {
	Type        string `json:"type"`
	OccurredOn  string `json:"occurred_on"`
	Description string `json:"description"`
}

type lifecycleEvent struct {
	ID          string `json:"id"`
	Type        string `json:"type"`
	OccurredOn  string `json:"occurred_on"`
	Description string `json:"description"`
	Source      string `json:"source"`
}

var (
	base   = os.Getenv("BATTERYPASS_URL") + "/api/v1"
	client = &http.Client{Timeout: 30 * time.Second}
)

// send calls the API and decodes a successful JSON answer into out; it returns the HTTP status.
func send(method, path string, body, out any) (int, error) {
	var buf bytes.Buffer
	if body != nil {
		if err := json.NewEncoder(&buf).Encode(body); err != nil {
			return 0, err
		}
	}
	req, err := http.NewRequest(method, base+path, &buf)
	if err != nil {
		return 0, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("BATTERYPASS_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, err := client.Do(req)
	if err != nil {
		return 0, err
	}
	defer res.Body.Close()
	if res.StatusCode >= 300 {
		return res.StatusCode, nil
	}
	return res.StatusCode, json.NewDecoder(res.Body).Decode(out)
}

// findEvent looks for an event this integration already recorded (same type, day and description).
func findEvent(batteryID string, e newEvent) (*lifecycleEvent, error) {
	query := url.Values{"limit": {"200"}}
	for {
		var page struct {
			Items      []lifecycleEvent `json:"items"`
			NextCursor *string          `json:"next_cursor"`
		}
		status, err := send(http.MethodGet, "/batteries/"+batteryID+"/lifecycle-events?"+query.Encode(), nil, &page)
		if err != nil {
			return nil, err
		}
		if status != http.StatusOK {
			return nil, fmt.Errorf("listLifecycleEvents: %d", status)
		}
		older := false
		for _, x := range page.Items {
			if x.Source == "api" && x.Type == e.Type && x.OccurredOn == e.OccurredOn && x.Description == e.Description {
				return &x, nil
			}
			older = older || x.OccurredOn < e.OccurredOn
		}
		// Newest first: once a page reaches older days, the event is not there.
		if older || page.NextCursor == nil {
			return nil, nil
		}
		query.Set("cursor", *page.NextCursor)
	}
}

func recordEvent(batteryID string, e newEvent) (*lifecycleEvent, error) {
	for attempt := 1; attempt <= 3; attempt++ {
		var created lifecycleEvent
		status, err := send(http.MethodPost, "/batteries/"+batteryID+"/lifecycle-events", e, &created)
		if err == nil && status == http.StatusCreated {
			return &created, nil
		}
		if err == nil && status < 500 {
			return nil, fmt.Errorf("createLifecycleEvent: %d", status)
		}
		// No answer or a 5xx: the event may have been recorded anyway.
		existing, err := findEvent(batteryID, e)
		if err != nil {
			return nil, err
		}
		if existing != nil {
			return existing, nil
		}
	}
	return nil, errors.New("createLifecycleEvent: no success after 3 attempts")
}

func main() {
	e, err := recordEvent("0192f0a3-1b7c-7e2a-8f45-6a9d0c3e5b12", newEvent{
		Type:        "inspection",
		OccurredOn:  "2026-10-24",
		Description: "Service order SO-2026-0815: annual inspection",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(e.ID)
}
```

The samples treat a `429` like any other refusal; combine them with the back-off from
[Rate limits](https://passport.kabin.sh/docs/en/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.
