Guide
eSigKit uses optimistic concurrency on every mutable resource. Each record
carries a version: number field. To update, you send the version you last
read; the server only accepts your update if the version still matches. If
another caller updated the record in the meantime, your request returns 409
and you’re expected to read fresh and retry.
This is the same pattern Stripe and most modern APIs use — it lets you avoid locking, prevents lost updates, and makes the failure mode visible at the API layer instead of buried as silent overwrites.
The pattern
- Read the current resource. Note its
version. - Modify locally.
- Write with the original
versionin the body. - On
409: re-read, re-modify, re-write.
async function updateUser(orgId: string, userId: string, patch: Record<string, unknown>) {
for (let attempt = 0; attempt < 3; attempt++) {
const current = await fetch(`/v1/orgs/${orgId}/users/${userId}`).then((r) => r.json());
const res = await fetch(`/v1/orgs/${orgId}/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` },
body: JSON.stringify({ ...patch, version: current.version }),
});
if (res.ok) return res.json();
if (res.status === 409 && attempt < 2) {
// Another caller won the race. Re-read and retry.
continue;
}
throw new Error(`update failed: ${res.status}`);
}
}
409 response
{
"error": {
"code": "CONFLICT",
"message": "Version mismatch — read fresh and retry",
"correlationId": "00000000-0000-4000-8000-000000000005"
}
}
The handler doesn’t include the current version in the error body — call the GET endpoint to read it. (We may surface it inline in a future API version; not yet.)
Which resources use it
Every resource with a version field. Today that’s:
- Orgs (
PUT /v1/orgs/{orgId}) - Users (
PUT /v1/orgs/{orgId}/users/{userId}) - Templates (
PUT /v1/orgs/{orgId}/templates/{templateId}) - Brand (
PUT /v1/orgs/{orgId}/brand)
POST (creation) endpoints don’t take a version — they’re either idempotent on a natural key (e.g., POST /orgs uses Cognito sub for idempotency) or generate fresh UUIDs.
What if the conflict keeps recurring?
Two clients hammering the same row will starve each other. Three patterns to handle:
- Backoff between retries. Add 50-200ms with jitter on each conflict so concurrent retries don’t keep colliding.
- Field-level merge. Read both your changes and the conflicting state,
merge non-conflicting fields, re-write. This is what
withMergewould do if we exposed it; today you have to do it client-side. - Coordinate writes. If you have a hot row (e.g., a singleton org row updated by many admins simultaneously), serialise via a queue on your side. eSigKit’s API doesn’t currently expose distributed-lock primitives.
After 3 conflicts in a row, escalate — log + surface a “saved elsewhere; refresh to see the latest” message rather than retry indefinitely. The dashboard does this on the user-edit sheet.