Saving
Summary
When the user presses Ctrl+S, the client sends a SaveChangeset containing only the fields that changed. Your server validates, persists, and returns a fresh ScreenContract.
What the client sends
Route
The client POSTs to the URL from actions.save on the current ScreenContract.
POST /screen/customer_card/10000/save
Content-Type: application/jsonSaveChangeset
json
{
"screen_id": "customer_card",
"record_id": "10000",
"changes": {
"name": "Acme Corporation",
"post_code": "100",
"city": "Tórshavn"
},
"action": null,
"lines": []
}| Property | Type | Purpose |
|---|---|---|
screen_id | string | Echoed from ScreenContract.screen_id — the stable machine identifier you set on the screen |
record_id | string | Record identifier (e.g., "10000") |
changes | object | Changed field values: field ID → new value |
action | string | "create" on new records, otherwise null. Reserved — do not rely on this for routing; use the URL path. |
lines | array | Grid line data for HeaderLines and Grid screens |
Key facts about changes
- Sparse. Only changed fields are included. If the user edited 3 fields out of 20, only those 3 appear in
changes. - Keys are field IDs. They match the
idproperty of the field definitions you sent. - Values are always strings, never null. An empty field is
"", notnull. Your server parses and validates. - Autofill-updated fields are included. If selecting a postal code autofilled a city value, both
post_codeandcityappear inchanges.
What your server should do
- Identify the record from the URL path (the path is authoritative, not the body)
- Validate — check required fields, business rules, cross-field constraints
- Persist — update the database
- Return a fresh ScreenContract — the complete card with updated values
What to return
Success
Return HTTP 200 with a ScreenContract reflecting the saved state:
json
{
"layout": "Card",
"title": "Customer Card - 10000",
"status": "Saved.",
"sections": [],
"actions": {
"save": "/screen/customer_card/10000/save",
"delete": "/screen/customer_card/10000/delete"
}
}The client replaces its current screen with your response. Dirty indicators are cleared. Computed values are recalculated (because you return fresh values).
Use the status field to show a success message: "Saved.", "Created.", etc.
Business logic errors
Return HTTP 200 with the error in status:
json
{
"layout": "Card",
"title": "Customer Card - 10000",
"status": "Cannot save: Name is required.",
"sections": []
}The client replaces the screen and shows the error in the bottom bar. The card values reflect your server's state (the save was rejected, so values revert to the last saved state).
Do not use HTTP error codes for business logic failures. Use HTTP 200 + status.
Infrastructure errors
For actual server errors (database down, record not found, etc.), return the appropriate HTTP status code (400, 404, 500) with an ErrorResponse body:
json
{ "error": "Record not found.", "code": "not_found" }error is required. code is optional — a machine-readable slug for the error type.
The client shows a generic error message and keeps the card open with dirty values intact, so the user can retry.
Concurrency
The protocol uses last-write-wins. There is no ETag, version field, or optimistic locking mechanism. If two users save the same record simultaneously, the second save overwrites the first. This is a known trade-off. For records where concurrent editing is a real concern, implement server-side locking in your application logic.
Saving HeaderLines documents
For documents with line items, the lines field contains the full grid data:
json
{
"screen_id": "sales_order",
"record_id": "SO-1001",
"changes": {
"sell_to_name": "Acme Corporation"
},
"lines": [
[
"Item",
"1000",
"Bicycle",
"PCS",
"2",
"1,495.00",
"0.00",
"2,990.00"
],
[
"Item",
"1100",
"Chain",
"PCS",
"4",
"45.00",
"10.00",
"162.00"
],
[
"",
"",
"",
"",
"",
"",
"",
""
]
]
}Important rules for lines
- Full grid data. The client sends all rows, not just changed ones. This simplifies server logic at the cost of payload size. Design grids for practical working sets — hundreds of rows are fine, tens of thousands are not.
- Column order is the contract. Each inner array uses the same column ordering as your
TableSpec.columnsarray. Parse by positional index. - Empty trailing rows. The grid includes empty rows at the end. Your server should ignore them.
- Values are always strings, never null. Empty cells are
"". Your server parses and validates.
Parsing lines
Map positions to columns using your column definition:
Position 0 → "type" column
Position 1 → "no" column
Position 2 → "description" column
...If you change the column order in your grid definition, update your parsing logic to match.
Saving Grid screens
Grid saves work the same as HeaderLines, but with no header changes. The changes map is empty and lines contains all grid data:
json
{
"screen_id": "banking_journal",
"record_id": "",
"changes": {},
"lines": [
[
"16-03-2026",
"Payment",
"PAY-001",
"Customer",
"10000",
"Acme Corp",
"-500.00"
]
]
}Posting
Posting is handled through Screen Actions, not through the save endpoint. Define a confirm kind screen action with its own endpoint. The server processes the journal lines and returns an ActionResponse with the updated screen.
See the Banking Journal example for the full posting flow.
Creating records
When the user creates a new record (Ctrl+N), the client fetches actions.create:
GET /screen/customer_card/new → ScreenContract (empty card)When they save the new card, the client POSTs to the save URL on that card:
POST /screen/customer_card/new
Body: SaveChangeset with the entered valuesReturn a ScreenContract with the newly created record (including the assigned record ID).