Updating Data

Optimistic Concurrency Control (OCC)

Overview

Sivo APIs implement Optimistic Concurrency Control (OCC) for requests that modify an existing resource. When a client retrieves a resource, works with it, and later updates it, another update may land in between. Without a guard, the second update silently overwrites the first and data is lost.

The If-Match HTTP header is that guard. It carries the version of the resource the client last saw, and the update is only applied if the resource still has that version. Sivo uses the resource's updated_at timestamp as the version.

Which requests require it

The If-Match header is required on every PATCH endpoint:

  • PATCH /debt-lines/{id}
  • PATCH /debt-line-terms/{id}
  • PATCH /financials/{id}
  • PATCH /payin-instruments/{id}
  • PATCH /annotations/{id}

It is also required on the POST actions that change the state of an existing resource:

  • POST /debt-lines/{id}/generate
  • POST /debt-lines/{id}/submit
  • POST /debt-line-terms/{id}/approve
  • POST /debt-line-terms/{id}/accept
  • POST /debt-line-terms/{id}/decline
  • POST /debt-line-terms/{id}/cancel

POST endpoints that create a new resource do not take the header; use an idempotency key for those instead (see Idempotent Requests).

Header format

If-Match: 2025-07-09T18:30:00.000Z

The value is the updated_at field of the resource as returned by the API. It is an ISO 8601 date-time in UTC.

🚧

Send updated_at exactly as you received it

The server compares the header to the stored timestamp as an exact string. A value that represents the same instant but is formatted differently, such as 2025-07-09T18:30:00Z without milliseconds or 2025-07-09T18:30:00.000+00:00 with an offset, passes validation but never matches, so every request fails with 412 Precondition Failed. Store the string the API returned and send it back unchanged.

Recommended flow

  1. Retrieve the resource and keep its updated_at value.
  2. Send the PATCH (or POST action) with that value in the If-Match header.
  3. Every successful response includes the new updated_at. Use it for the next update to the same resource.
  4. On 412 Precondition Failed, retrieve the resource again, check whether your change still makes sense against the latest version, and resubmit with the new updated_at if it does.

Keep one updated_at per resource, not one per client. Two resources updated by the same process have different timestamps.

Omitting and clearing fields

Updates are partial. This applies to every PATCH endpoint and to the create-or-update (upsert) POST endpoints that are keyed by an id you assign, such as accounts, receivables, and receivable entries.

  • A field you omit from the body is left unchanged.
  • An optional field you send as null is cleared.
  • Required fields cannot be cleared. Sending null for one is rejected with 400 Bad Request.
{ "term": "60 days", "fund_name": null }

The request above changes term and clears fund_name, leaving every other field as it was.

On an upsert, the fields marked "Required at creation" in the reference must be present when the id is new. Once the resource exists, a request with the same id may send any subset of fields and behaves like a PATCH. Upsert endpoints do not take the If-Match header.

Example

Rename a debt line, retrying once if it was modified in between:

const baseUrl = 'https://core.sivo.com';
const headers = { Authorization: apiKey, 'Content-Type': 'application/json' };

const getDebtLine = async (id: string) => {
  const response = await fetch(`${baseUrl}/debt-lines/${id}`, { headers });
  return response.json();
};

const renameDebtLine = async (id: string, name: string) => {
  let { updated_at } = await getDebtLine(id);
  for (let attempt = 0; attempt < 2; attempt++) {
    const response = await fetch(`${baseUrl}/debt-lines/${id}`, {
      method: 'PATCH',
      headers: { ...headers, 'If-Match': updated_at },
      body: JSON.stringify({ name }),
    });
    if (response.status === 412) {
      // Someone else updated the debt line; refresh and try once more.
      ({ updated_at } = await getDebtLine(id));
      continue;
    }
    if (!response.ok) throw new Error(`update failed: ${response.status}`);
    return response.json();
  }
  throw new Error('debt line keeps changing; giving up');
};

Errors

All three errors return the standard error object described in Errors. Branch on the status code, not the message text, which may change.

400 Bad Request — header missing

The request did not include an If-Match header.

{ "message": "missing header: [if-match]" }

400 Bad Request — header invalid

The header value is not an ISO 8601 date-time.

{ "message": "invalid if-match header: yesterday" }

412 Precondition Failed — resource modified

The stored updated_at differs from the header. Either the resource was updated since you retrieved it, or the header was not sent exactly as returned (see the note above).

{ "message": "etag does not match latest version. please fetch the latest and try again." }

Retrieve the latest version, re-apply your change if it is still appropriate, and resubmit with the new updated_at. See also Status Codes.