Skip to content

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

SaveChangeset

json
{
  "screen_id": "customer_card",
  "record_id": "10000",
  "changes": {
    "name": "Acme Corporation",
    "post_code": "100",
    "city": "Tórshavn"
  },
  "action": null,
  "lines": []
}
PropertyTypePurpose
screen_idstringEchoed from ScreenContract.screen_id — the stable machine identifier you set on the screen
record_idstringRecord identifier (e.g., "10000")
changesobjectChanged field values: field ID → new value
actionstring"create" on new records, otherwise null. Reserved — do not rely on this for routing; use the URL path.
linesarrayGrid 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 id property of the field definitions you sent.
  • Values are always strings, never null. An empty field is "", not null. Your server parses and validates.
  • Autofill-updated fields are included. If selecting a postal code autofilled a city value, both post_code and city appear in changes.

What your server should do

  1. Identify the record from the URL path (the path is authoritative, not the body)
  2. Validate — check required fields, business rules, cross-field constraints
  3. Persist — update the database
  4. 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.columns array. 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 values

Return a ScreenContract with the newly created record (including the assigned record ID).