Write data safely
Create, replace, update and delete rows without losing someone else's change, retry a create without writing it twice, and write many rows in one transaction.
Before you start
Section titled “Before you start”- The stack from Quick start, serving
examples/vehicle-registry, run from the directory that holdsdocker-compose.quickstart.yml, withALVO_DEMO_KEY_SECRETandALVO_ADMIN_PASSWORDexported in this shell. - The
demokey (rolesadmin,authenticated). Onlyadminmay create owners and vehicles.
Start from an empty database so your results match the ones below. The first command deletes the stack’s database:
docker compose -f docker-compose.quickstart.yml down --volumesunset ALVO_DESCRIPTORdocker compose -f docker-compose.quickstart.yml up --wait --wait-timeout 90The responses below were captured from a real host when the site was built, so your ids, timestamps and ETags will differ.
1. Create or replace a row with PUT
Section titled “1. Create or replace a row with PUT”POST /api/<entity> creates a row and Alvo picks its id. PUT /api/<entity>/<id> uses the id you choose: it creates
the row when it does not exist (201, with Location) and replaces it when it does (200):
curl -sS -X PUT http://localhost:8080/api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Fleet Desk Ltd","email":"office@fleetdesk.example"}'201 Created
HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155309171920"Location: /api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d
{ "id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "name": "Fleet Desk Ltd", "email": "office@fleetdesk.example", "phone": null}PUT replaces the whole row. A field the body leaves out is written null, unless it declares a literal default,
which is filled in instead. Here the second PUT sends no email, so the owner loses it:
curl -sS -X PUT http://localhost:8080/api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Fleet Desk Limited"}'200 OK
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8ETag: "639273155309523090"
{ "id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "name": "Fleet Desk Limited", "email": null, "phone": null}To change only some fields, use PATCH, which merges the body into the stored row. Because a PUT can create or
replace depending on what is stored, the caller needs both the create and the update rule, and both are checked
against the row it would write. The id goes in the path only; a body that names id is refused on every route.
2. Retry a create without writing it twice
Section titled “2. Retry a create without writing it twice”A create whose response is lost, to a timeout or a dropped connection, leaves you not knowing whether the row exists.
Send an Idempotency-Key header, a string you choose for this one operation, and retry with the same key:
curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Idempotency-Key: import-2026-10-10-row-1" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBJJ7NE8L0123456","plate":"BA-101AA","make":"Skoda","model":"Octavia","year":2020,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}'201 Created
HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155309654570"Location: /api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724
{ "id": "4903fb56-1628-476e-a4f1-ac6658c97724", "plate": "BA-101AA", "model": "Octavia"}The retry writes nothing. It answers like the first request, with the same row:
curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Idempotency-Key: import-2026-10-10-row-1" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBJJ7NE8L0123456","plate":"BA-101AA","make":"Skoda","model":"Octavia","year":2020,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}'201 Created
HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155309654570"Location: /api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724
{ "id": "4903fb56-1628-476e-a4f1-ac6658c97724", "plate": "BA-101AA", "model": "Octavia"}The same key with a different body is not a retry, and is refused rather than answered with the first row:
curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Idempotency-Key: import-2026-10-10-row-1" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBEG7NJ1N0234567","plate":"BA-202BB","make":"Skoda","model":"Fabia","year":2022,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}'409 Conflict
HTTP/1.1 409 ConflictContent-Type: application/problem+json
{ "type": "https://alvo.dev/errors/idempotency-conflict", "title": "Conflict", "status": 409, "detail": "This idempotency key was already used for a different request. Reusing one key for two requests would silently discard the second, so send a fresh key."}Alvo stores only the ids of the rows the write touched under the key, scoped to the caller (their user and tenant),
and on a replay reads the row again with the caller’s current get rule; it never stores a response. A key is at most
255 UTF-8 bytes.
A caller without a key cannot use one, because every anonymous caller shares one identity, and is refused with a 422.
The header is honoured on every write: POST, PUT, PATCH, DELETE and the three batch verbs. A replayed PUT
answers 200 without Location.
3. Update only what you have seen: ETag and If-Match
Section titled “3. Update only what you have seen: ETag and If-Match”On an entity with "audit": true, every single-row response also carries an ETag, its version. Send it back as
If-Match and the write happens only if nobody has changed the row since you read it. Update the vehicle from step 2
with the ETag its create returned:
curl -sS -X PATCH http://localhost:8080/api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724 \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "If-Match: \"639273155309654570\"" \ -H "Content-Type: application/json" \ -d '{"color":"green"}'200 OK
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8ETag: "639273155310170160"
{ "id": "4903fb56-1628-476e-a4f1-ac6658c97724", "color": "green", "created_at": "2026-10-11T11:38:50.965457+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "make": "Skoda", "model": "Octavia", "owner_id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "plate": "BA-101AA", "updated_at": "2026-10-11T11:38:51.017016+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "vin": "TMBJJ7NE8L0123456", "year": 2020}The row now has a new ETag. A client still holding the old one is refused, instead of silently overwriting the
change it never saw:
curl -sS -X PATCH http://localhost:8080/api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724 \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "If-Match: \"639273155309654570\"" \ -H "Content-Type: application/json" \ -d '{"color":"black"}'412 Precondition Failed
HTTP/1.1 412 Precondition FailedContent-Type: application/problem+json
{ "type": "https://alvo.dev/errors/precondition-failed", "title": "Precondition Failed", "status": 412, "detail": "The record was changed since the version this write carries. Re-read it and retry."}On a 412, read the row again, apply your change to what is stored now, and send it with the new tag. DELETE takes
If-Match the same way. A few rules, all refusals rather than silent passes:
- An entity without
audithas no version, so its rows carry noETag, andIf-Matchwith a tag is refused with a 412.If-Match: *asks only that the row exist and is accepted everywhere. - A weak tag (
W/"…"), a list of tags,If-None-Matchon a write, and any precondition on a create are refused with a 412. - On a read of one row,
If-None-Matchwith the current tag answers 304 with no body.
4. Write many rows in one transaction
Section titled “4. Write many rows in one transaction”/api/<entity>/batch takes {"rows": [ … ]} with three verbs: POST creates, PATCH updates (each row carries its
id) and DELETE removes (each row is a bare id). Create two vehicles:
curl -sS -X POST http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":[{"vin":"WVWZZZAUZKW345678","plate":"KE-303CC","make":"Volkswagen","model":"Golf","year":2019,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"WVWZZZ3CZPE456789","plate":"KE-404DD","make":"Volkswagen","model":"Passat","year":2023,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}]}'200 OK
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8
{ "affected": 2}Update both, naming them by the ids the create returned:
curl -sS -X PATCH http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":[{"id":"1c9e6a9e-d24b-4bae-8a93-8fd5a610b8fa","color":"silver"},{"id":"4f9b6d5f-730d-42d2-8dc3-93460f169bcc","color":"silver"}]}'200 OK
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8
{ "items": [ { "id": "1c9e6a9e-d24b-4bae-8a93-8fd5a610b8fa", "color": "silver", "created_at": "2026-10-11T11:38:51.028367+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "make": "Volkswagen", "model": "Golf", "owner_id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "plate": "KE-303CC", "updated_at": "2026-10-11T11:38:51.039132+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "vin": "WVWZZZAUZKW345678", "year": 2019 }, { "id": "4f9b6d5f-730d-42d2-8dc3-93460f169bcc", "color": "silver", "created_at": "2026-10-11T11:38:51.028367+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "make": "Volkswagen", "model": "Passat", "owner_id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "plate": "KE-404DD", "updated_at": "2026-10-11T11:38:51.039132+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "vin": "WVWZZZ3CZPE456789", "year": 2023 } ], "affected": 2}And delete them. A batch delete answers 200 with affected, so a five-row delete can be told from a refusal:
curl -sS -X DELETE http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":["1c9e6a9e-d24b-4bae-8a93-8fd5a610b8fa","4f9b6d5f-730d-42d2-8dc3-93460f169bcc"]}'200 OK
HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8
{ "items": [], "affected": 2}Every row is checked before any row is written, so one bad row writes nothing, and the refusal lists every bad row with
a pointer into rows. Here the second row has no year:
curl -sS -X POST http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":[{"vin":"VF1RJA00X67567890","plate":"ZA-505EE","make":"Renault","model":"Clio","year":2017,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"VF1RJA00X67567891","plate":"ZA-606FF","make":"Renault","model":"Captur","owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}]}'422 Unprocessable Entity
HTTP/1.1 422 Unprocessable EntityContent-Type: application/problem+json
{ "type": "https://alvo.dev/errors/validation", "title": "Unprocessable Entity", "status": 422, "detail": "A field the entity declares required is missing or null.", "violations": [ { "pointer": "/rows/1/year", "code": "required", "message": "A field the entity declares required is missing or null.", "fixSuggestion": "Supply a value for it. A create must carry every required field; a partial update may omit any field it is not changing, but may not null a required one." } ]}A row refused by a rule makes the whole batch a 403 whose violations name each refused row. A batch holds up to 1000
rows (Limits and budgets), may not name one row twice, and takes no If-Match: one
version cannot condition many rows. A batch update stamps each row’s updated_at and updated_by as a single update
does, so each row gets a new ETag and a tag taken before the batch no longer matches. Every row written still emits its own event, so an after-hook or webhook runs once
per row (#193).
5. Send JSON, and say so
Section titled “5. Send JSON, and say so”Every request with a body must declare Content-Type: application/json. Any other media type, or none at all, is
refused before the body is read, and the answer names the type it accepts:
curl -sS -X POST http://localhost:8080/api/owners \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: text/plain" \ -d '{"name":"Fleet Desk Ltd"}'415 Unsupported Media Type
HTTP/1.1 415 Unsupported Media TypeContent-Type: application/problem+jsonAccept-Post: application/json
{ "type": "https://alvo.dev/errors/unsupported-media-type", "title": "Unsupported Media Type", "status": 415, "detail": "This endpoint reads a JSON request body. Send it with 'Content-Type: application/json'."}Also accepted: any application/*+json type, such as application/merge-patch+json, and a charset parameter, which
is ignored (bodies are read as UTF-8). The requirement has no switch. It is what keeps a cross-site form from writing:
a browser sends a form as text/plain or application/x-www-form-urlencoded without asking the server first, and it
never sends application/json that way.
How it works
Section titled “How it works”Each write runs in one database transaction. Alvo checks the body against the entity’s fields; then, over the locked
row, it runs the before-hooks, checks the create or update rule against the row
it is about to store and compares If-Match with the row’s version; it writes the row and records its event before it
commits. A write
answers with the row exactly as a GET by the same caller would show it; when the caller may not read that row, the
answer carries only its id. Security model explains the order of the checks.
Options and variations
Section titled “Options and variations”| You want to | Send | Notes |
|---|---|---|
| Create with a generated id | POST /api/<entity> | 201 and Location. |
| Create or replace under your id | PUT /api/<entity>/<id> | 201 on create, 200 on replace; needs create and update. |
| Change some fields | PATCH /api/<entity>/<id> | Merges the fields you send into the row. |
| Delete | DELETE /api/<entity>/<id> | 204 with no body. |
| Retry safely | Idempotency-Key: <your key> | On every write: POST, PUT, PATCH, DELETE and the batch verbs. |
| Not overwrite a change | If-Match: <ETag> | On PATCH, PUT and DELETE of an audited entity. |
| Write many rows | POST, PATCH or DELETE /api/<entity>/batch | All or nothing, up to 1000 rows. |
Headers, statuses and route shapes are summarised in Data API conventions.
What can go wrong
Section titled “What can go wrong”A value another row already holds on a unique field is a conflict only the database can detect:
curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBJJ7NE8L0999999","plate":"BA-101AA","make":"Skoda","model":"Superb","year":2024,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}'409 Conflict
HTTP/1.1 409 ConflictContent-Type: application/problem+json
{ "type": "https://alvo.dev/errors/conflict", "title": "Conflict", "status": 409, "detail": "This field is declared unique and another record already holds the value sent for it.", "violations": [ { "pointer": "/plate", "code": "unique", "message": "This field is declared unique and another record already holds the value sent for it.", "fixSuggestion": "Send a value no other record holds, or change the record that holds it." } ]}| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 412 | precondition-failed | If-Match names a version the row no longer has, or a precondition Alvo cannot evaluate. | Read the row again and retry with its ETag. | every host |
| 409 | idempotency-conflict | The Idempotency-Key was already used for a different request. | Send a new key. | every host |
| 409 | conflict | A unique value another row holds, or a delete a restrict reference blocks. | Send a different value, or remove what references the row. | every host |
| 415 | unsupported-media-type | The body is not declared as JSON, or no Content-Type is sent. | Send Content-Type: application/json. | every host |
| 422 | validation | A field is missing, too long, of the wrong type, unknown or read-only; a batch is empty or has too many rows. | Fix each field a violation points at. | every host |
| 422 | malformed-query | An Idempotency-Key is longer than 255 bytes, or sent without an API key. | Shorten it, or authenticate. | every host |
| 403 | forbidden | A rule or a before-hook refused the write; in a batch, violations name each refused row. | Check the create or update rule and the hooks. | every host |
| 404 | not-found | The row of a PATCH or DELETE does not exist, or your rule excludes it. | Check the id and the rule. | every host |
| 413, 408, 400 | unreadable-request | The web server refused the body before Alvo read it: too large, too slow, or broken framing. | Send a smaller body, or split a batch. | standalone; embedded only with AddAlvoProblemDetails() |
Reference
Section titled “Reference”- Data API conventions and Limits and budgets.
- Descriptor keys:
audit, which gives rows a version. - Problem types:
precondition-failed,idempotency-conflict,conflict,unsupported-media-type,validation,unreadable-request. - Design notes: optimistic concurrency,
the batch,
Idempotency-Key, create-or-replace and the JSONContent-Typerequirement.
Handle errors: read a problem document and branch on its type.