Idempotency
Transaction IDs are generated by the client and double as idempotency keys, so retries never duplicate.
Networks fail. If a request to add a transaction times out, you don't know whether it was saved. SpendRock makes retrying safe: the client picks the transaction's ID, and that ID is the idempotency key.
How it works
When you create a transaction with POST /transactions, you send an id: a new
ULID you generate.
| You send | You get |
|---|---|
A new id | 201 Created with the transaction and the recomputed month(s). |
The same id and the same body again | 200 OK with the stored result. Nothing is created twice. |
The same id with a different body | 409 Conflict with the code id_conflict. |
So the rule for clients is simple: generate the ID once, before the first attempt, and reuse it for every retry of that same transaction. Never generate a new ID for a retry.
import { ulid } from 'ulid';
const txn = {
id: ulid(), // once, up front
type: 'expense',
amount: 5420,
date: '2026-12-03',
merchant: 'Corner Grocery',
budget_month: '2026-12',
splits: [{ item_id: groceriesId, amount: 5420 }],
};
// Safe to call as many times as needed: at most one transaction is created.
async function save() {
const res = await fetch(`${SR_API}/transactions`, {
method: 'POST',
headers: { Authorization: `Bearer ${SR_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(txn),
});
if (res.status === 201 || res.status === 200) return res.json();
throw new Error(`${res.status}: ${(await res.json()).code}`);
}This is exactly how the mobile app saves expenses you add while offline: they're stored on the device with their ID and uploaded when the connection returns, and nothing is lost or duplicated.
Other requests
- Updates (
PATCH) set fields to the values you send, so repeating one is harmless. - Deleting a transaction is a soft delete: nothing is lost, and
POST /transactions/{id}/restorebrings it back. - Other creates (groups, items, households, invites) use server-generated IDs and are not
idempotent. If one times out, list or read before retrying. Names of groups and items are unique
within a month, so a duplicate create usually fails with
name_takenrather than creating a copy.