Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

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.

  • The stack from Quick start, serving examples/vehicle-registry, run from the directory that holds docker-compose.quickstart.yml, with ALVO_DEMO_KEY_SECRET and ALVO_ADMIN_PASSWORD exported in this shell.
  • The demo key (roles admin, authenticated). Only admin may create owners and vehicles.

Start from an empty database so your results match the ones below. The first command deletes the stack’s database:

Terminal
docker compose -f docker-compose.quickstart.yml down --volumes
unset ALVO_DESCRIPTOR
docker compose -f docker-compose.quickstart.yml up --wait --wait-timeout 90

The responses below were captured from a real host when the site was built, so your ids, timestamps and ETags will differ.

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):

Request
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

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
ETag: "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:

Request
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

Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
ETag: "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:

Request
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

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
ETag: "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:

Request
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

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
ETag: "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:

Request
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

Response
HTTP/1.1 409 Conflict
Content-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:

Request
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

Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
ETag: "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:

Request
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

Response
HTTP/1.1 412 Precondition Failed
Content-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 audit has no version, so its rows carry no ETag, and If-Match with 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-Match on a write, and any precondition on a create are refused with a 412.
  • On a read of one row, If-None-Match with the current tag answers 304 with no body.

/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:

Request
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

Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"affected": 2
}

Update both, naming them by the ids the create returned:

Request
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

Response
HTTP/1.1 200 OK
Content-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:

Request
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

Response
HTTP/1.1 200 OK
Content-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:

Request
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

Response
HTTP/1.1 422 Unprocessable Entity
Content-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).

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:

Request
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

Response
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json
Accept-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.

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.

You want toSendNotes
Create with a generated idPOST /api/<entity>201 and Location.
Create or replace under your idPUT /api/<entity>/<id>201 on create, 200 on replace; needs create and update.
Change some fieldsPATCH /api/<entity>/<id>Merges the fields you send into the row.
DeleteDELETE /api/<entity>/<id>204 with no body.
Retry safelyIdempotency-Key: <your key>On every write: POST, PUT, PATCH, DELETE and the batch verbs.
Not overwrite a changeIf-Match: <ETag>On PATCH, PUT and DELETE of an audited entity.
Write many rowsPOST, PATCH or DELETE /api/<entity>/batchAll or nothing, up to 1000 rows.

Headers, statuses and route shapes are summarised in Data API conventions.

A value another row already holds on a unique field is a conflict only the database can detect:

Request
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

Response
HTTP/1.1 409 Conflict
Content-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."
}
]
}
StatusProblem typeWhenFixReturned by
412precondition-failedIf-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
409idempotency-conflictThe Idempotency-Key was already used for a different request.Send a new key.every host
409conflictA unique value another row holds, or a delete a restrict reference blocks.Send a different value, or remove what references the row.every host
415unsupported-media-typeThe body is not declared as JSON, or no Content-Type is sent.Send Content-Type: application/json.every host
422validationA 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
422malformed-queryAn Idempotency-Key is longer than 255 bytes, or sent without an API key.Shorten it, or authenticate.every host
403forbiddenA 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
404not-foundThe row of a PATCH or DELETE does not exist, or your rule excludes it.Check the id and the rule.every host
413, 408, 400unreadable-requestThe 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()

Handle errors: read a problem document and branch on its type.