Concepts
Idempotency and revisions
Retry without doing things twice, and never overwrite someone else's change.
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.
Idempotency-Key: 2f1c8a52-5d6b-4f0e-9a1d-7c3e2b1a0f9dYou 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.
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:
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, which throws the error code:
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));
}
}