# Idempotency and revisions

> Retry without doing things twice, and never overwrite someone else's change.

Source: https://app.getoatmilk.com/docs/idempotency

Networks fail. A request can reach Oatmilk and its response can still be lost on the way back. Two tools make it safe to try again: idempotency keys stop a change from happening twice, and revisions stop you from overwriting a change you haven't seen.

## Idempotency keys

Generate a unique key once for each change you intend to make, such as a UUID, and send it with the request. If you send the same request again with the same key, Oatmilk returns the first result instead of doing the work again.

```http
Idempotency-Key: 2f1c8a52-5d6b-4f0e-9a1d-7c3e2b1a0f9d
```

You can send the key as the `Idempotency-Key` header or as the `idempotencyKey` field in the body. If you send both, they must match. Actions that change something list the key on their reference page, and most require it.

- **Make one key per intended change**, not per attempt. Save it before you send the request so a crash can't lose it.
- **Reuse it only for exactly the same input.** The same key with a different input is refused with a conflict, because it would be ambiguous which one you meant.
- **Keys are per person.** Your key never collides with someone else's.

Wise syncs keep the accepted account plan, time cutoff and original source bytes. Retrying the same request and key continues a partial sync; after it finishes, retries return its saved response. That response describes the original sync, even if the books have changed since then. Use a new key for a new sync, including the next history pass using `nextFrom`.

Wise receipt work can return `receipts.status: "queued"`, with `imported: 0`, a queued count and job IDs. The receipt worker continues that work separately. A completed transaction sync does not mean those receipt jobs have finished.

Exact Wise response replay applies to requests accepted by the resumable sync system. If a known key from an older sync conflicts, the error asks you to start a new sync with a fresh key. Existing originals, bank lines and allocations are preserved; Oatmilk cannot reconstruct an older request's unsaved time cutoff.

> [!NOTE]
> Creating an API key or a webhook endpoint returns its secret once. If you replay the creation with the same key you get the record again, but without the secret. Rotate it if you lost the first response.

## Revisions

Records that people edit carry a `revision` number that goes up with every change. To change one, send the revision you last read as `expectedRevision`:

```bash
curl https://app.getoatmilk.com/api/v1/accounting/webhooks.endpoints.update \
  -H "Authorization: Bearer $OATMILK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "id": "5d4c3b2a-1f0e-4d9c-8b7a-6e5f4d3c2b1a",
  "expectedRevision": 1,
  "events": [
    "invoice.*",
    "party.*"
  ]
}'
```

If someone changed the record after you read it, Oatmilk refuses with `STALE_REVISION` or `CONFLICT` and changes nothing. Read the record again, check whether your change still makes sense, and send it with the new revision. Never guess a revision or retry with a higher number: that would overwrite a change nobody has looked at.

## Together

A safe write keeps its idempotency key across retries and reads the record again when its revision is out of date. `callOatmilk` is the helper from [Retrying safely](https://app.getoatmilk.com/docs/errors.md#retrying-safely), which throws the error code:

```js title="safe-update.js"
const key = crypto.randomUUID();
let record = await callOatmilk("webhooks.endpoints.list", {}).then(result => result.items[0]);
for (;;) {
  try {
    return await callOatmilk("webhooks.endpoints.update", { id: record.id, expectedRevision: record.revision, description: "CRM sync" }, key);
  } catch (error) {
    if (error.message !== "STALE_REVISION" && error.message !== "CONFLICT") throw error;
    record = await callOatmilk("webhooks.endpoints.list", {}).then(result => result.items.find(item => item.id === record.id));
  }
}
```
