Skip to content
HotLayout Docs

Data dictionary

This page is for the backend developer, and for whoever sets a project up in the admin. It answers what the data dictionary is, what your backend promises by it, and how to edit it.

Related pages: index · backend guide · delivery

An app developer who needs to know which values one screen reads: go to What a document reads.

What it is#

A screen built in HotLayout contains no user data. Where a value should appear, the document holds a reference to a field:

{ "kind": "binding", "path": "task.title", "fallback": "—" }

The data dictionary is the list of every path a screen is allowed to reference. It is one per project and shared by all documents of the project.

  • The builder offers managers only the paths of the dictionary.
  • Publication checks every binding of the document against it.
  • Your backend serves values at exactly these paths; the app merges them with the document on the device.

A dictionary has two sections: paths (single values) and collections (lists). In the file, data-contract.json, they stand under the contract key, next to the delivery rules:

{
  "x-dataContractVersion": "1.5.0",
  "x-delivery": { "pathsShape": "flat-map", "collectionShape": "array-of-records", "…": "…" },
  "contract": {
    "paths": [
      { "path": "user.firstName", "type": "string", "label": "First name", "sampleValue": "Alex" },
      { "path": "task.title", "type": "string", "label": "Task title", "sampleValue": "Make 5 card payments" },
      { "path": "task.completed", "type": "boolean", "label": "Task completed", "sampleValue": false },
      { "path": "campaign.endsAt", "type": "date", "label": "Campaign ends at", "sampleValue": "2026-11-30T12:00:00.000Z" }
    ],
    "collections": [
      { "path": "tasks", "label": "Tasks", "item": "task" }
    ]
  }
}

The file also carries a JSON Schema of this shape (x-schema) and a few descriptive keys; a reader that wants the dictionary takes contract. The webhook body carries the same two sections directly under its dataContract key, without the wrapper.

Paths#

A path describes one value.

Field Meaning
path entity.field — at least two parts joined by a dot. Each part is Latin letters, digits and _, and does not start with a digit
type string, number, date or boolean
label the human name a manager sees in the editor
sampleValue a realistic sample, of the same type as the path. The builder draws previews with it

Each path is described once.

Name fields exactly as your API does#

Path names must match your backend response verbatim. If the API returns title, the path is task.title, not task.name. A "friendlier" name would need a mapping layer between the response and the document — one more place that knows about fields, and the first to drift. The human name is the label.

Do not list fields your backend does not return. A promise nobody keeps shows up as an empty spot on a screen.

Types#

Type Value on the wire Sample
string JSON string any string
number JSON number a number
boolean true / false true or false
date JSON string, ISO 8601 with time and zone 2026-01-15T12:00:00Z

A date is a moment in time. Send 2026-10-31T21:59:59.000Z or 2026-10-31T23:59:59+02:00, never a bare 2026-10-31: a screen can show the value in the time zone of the user's device, and a string without a zone would be read differently by different renderers. Date samples follow the same rule; the editor rejects a sample that is not a date with time and zone.

An image is a string holding a ready-made https address. The app does not build image addresses from storage keys.

Samples#

Samples are what a manager sees while assembling a screen, so make them realistic: a real-looking amount, not the word "text".

A tip for date samples: use noon UTC (…T12:00:00.000Z). It falls on the same calendar day in every time zone, so the preview does not appear to be off by one day.

Collections#

A collection describes a list of records.

Field Meaning
path the name of the list: tasks, or a nested taskGroup.tasks
label the human name of the list
item the entity of one record, e.g. task

A collection does not describe the fields of its records again. It names an entity, and the fields are the entity.… paths already listed: the list tasks gives task records, whose fields are task.title, task.completed and so on. The dictionary must contain at least one path of that entity.

A consequence worth knowing: one template works both as a row of a list and as a details screen, because both read the same task.… paths.

A list path cannot be the same as a value path.

Nested collections#

A record can carry its own array. taskGroup.tasks describes the tasks array inside each taskGroup record. There are exactly two levels; a third is not allowed.

What your backend sends#

The delivery rules are also written machine-readably in the x-delivery block of data-contract.json:

"x-delivery": {
  "pathsShape": "flat-map",
  "collectionShape": "array-of-records",
  "missingValue": "null-or-absent",
  "absentCollection": "empty",
  "maxCollectionItems": 50
}

Single values: a flat map#

{
  "user.firstName": "Alex",
  "user.points": 1250,
  "campaign.title": "Autumn rewards",
  "campaign.endsAt": "2026-11-30T23:59:59.000Z"
}

Keys are the full paths.

Lists: arrays of records, without the entity prefix#

{
  "tasks": [
    { "title": "Spend 500 with your card", "progress": 3, "completed": false },
    { "title": "Set up a recurring payment", "progress": 1, "completed": true }
  ]
}

Record keys are plain field names — title, not task.title. This is what a list endpoint normally returns already. The app adds the prefix when it puts a record into scope.

A nested collection is simply an array inside the record:

{
  "taskGroups": [
    {
      "title": "Weekly streak",
      "completedTasks": 3,
      "tasks": [
        { "title": "Pay a utility bill", "completed": true },
        { "title": "Top up your balance", "completed": false }
      ]
    }
  ]
}

Missing values: null or absent#

When there is no value, send null or leave the key out. Do not send an empty string in its place: every binding in the document has its own fallback, and the fallback decides what is shown.

An absent collection counts as empty#

If the user has no tasks, send "tasks": []. An omitted key works too — the dictionary declares absentCollection: "empty", the same rule as missingConditionValue: "absent-is-empty" in the compatibility policy since contract 8.18.0: an isEmpty condition holds on an empty array and on an absent collection alike. Sending [] is still the better habit: it says "the list exists and is empty" instead of leaving silence to be interpreted.

At most 50 records#

The app draws at most 50 records of a collection (maxCollectionItems). You may send more, but the rest is cut off. A longer list needs pagination designed at the product level.

Showing dates: binding format#

A date value arrives as an ISO string, and by default a binding shows it as is. To show it as a date or a time, the binding carries a format:

format Shows
date year, short month, day
time hours and minutes, in the convention of the language
dateTime both
{ "kind": "binding", "path": "campaign.endsAt", "format": "date", "fallback": "—" }

The list is closed, and it names an intent, not a pattern: the exact appearance comes from the locale data of the platform, in the language of the screen and the time zone of the device. Formats apply only to paths of type date. The fallback is text typed by a person and is not formatted. A value that cannot be parsed as a date is shown as is. A value is a date only if it has the right shape and exists in the Gregorian calendar (2026-02-30T00:00:00Z and …T24:00:00Z are not dates). The calendar of the shown date is always Gregorian, whatever the language; a language the platform has no date data for shows the value as is.

Numbers, money and days left#

Since schema 9.4.0 the list has three more values:

format For a path of type Shows
number number the number with the group separators and decimal sign of the language: 1,850, 1 850. Up to three fraction digits
currency number the amount, a no-break space and an ISO 4217 code: 120 JOD, 1,850.50 USD. The code is a second field of the binding, currency
daysUntil date the number of whole days until the moment, rounded up, never below zero
{ "kind": "binding", "path": "offer.price", "format": "currency", "currency": "JOD", "fallback": "—" }

What to send, and what the app shows:

  • A number as a JSON number. The string "1850" is not formatted. A number above 2^53 − 1 is shown as is.
  • An empty string counts as "no value". The app shows the fallback of the binding, so do not send "" where you mean a real, empty-looking value.
  • A date in the form of the rule above — ISO 8601 with a time and a zone. 2026-10-14 or a timestamp number is shown as is.
  • A whole amount of money has no fraction (120 JOD); a fractional one gets the digits of its currency.
  • daysUntil is counted from the clock of the device at the moment the screen is drawn; the screen does not count down by itself. Both renderers let the app give its own clock (now): set it when a screen is rendered on a server and hydrated in a browser, so that both count from the same moment.

Since schema 9.19.0 a binding can say more about how money and a date are written. Your data does not change — these are fields of the document:

Field of the binding Shows
currencyDisplay: "symbol" the sign of the currency (₴, $, €…) instead of its code, from a closed table; a code the table does not have stays a code
currencyPosition: "before" the currency before the amount
sign: "always" a plus before a positive number
dateParts beside format: "date" dayMonth, weekdayDayMonth or dayMonthYear — which parts of the date are written

Paths a screen writes to#

A screen can also write: a text field, a switch, a checkbox, a choice group and a button with a write action each name a path of the dictionary, of exactly the type they write (text, number, yes/no). The app receives every write (onWrite) and decides what to do with it.

Two things follow for the dictionary and the backend:

  • List the paths a screen may write, with the right type — a form bound to a path that is not in the dictionary is not published.
  • A written value changes nothing on your side by itself. If other data depends on it — the details of the chosen item, a price for the chosen plan — your app has to supply that data after the write. See Data that depends on what the user chose.

Text with several values: templates#

Since schema 9.4.0 a text can be a template — one translatable string with named places and a map of values:

{
  "kind": "template",
  "template": "{days} {unit} left",
  "values": {
    "days": { "kind": "binding", "path": "campaign.endsAt", "format": "daysUntil", "fallback": "—" },
    "unit": { "kind": "plural", "of": "days", "forms": { "one": "day", "other": "days" } }
  }
}
  • A value of kind binding is the same binding as above, with its own fallback and format. When there is no data for it, its fallback is shown and the rest of the sentence stays.
  • A value of kind plural is a word that agrees with the number of another value (of). The form is chosen by the plural rules of the language (one, few, many, other, … — the CLDR categories); the forms are written per language in the Languages section. A document must name its language (turn on languages) to use it.
  • The string is translated as a whole, so every language puts the values in its own order: Лишилося {days} {unit}.
  • Only {name} with a name from values is replaced. Anything else, including {typo}, is shown as plain text.

Your backend sends the same data as before: a template only changes how the app writes it.

This is the reason for the "time and zone" rule above. Details are in the Widget reference; language selection is covered in Localization.

What a document reads#

The dictionary says what a project may use. One document uses a part of it, and which part is written in the document itself — so an app developer can find out what to supply from the published file alone, without the admin.

In the document's JSON What it is
an object with "kind": "binding" and a path a value the screen shows: a text, a number, a date, an image address, a value inside a template
visibleIf, disabledIf, and when inside styleWhen — objects with a path and an op a condition on a value
iconPath; the values of paramBindings in an action an icon name and an action parameter taken from the data
props.source of a node of type Repeater, when it is a string the collection a list is drawn from
props.value.path of a TextField, Switch, Checkbox or ChoiceGroup; params.path of a setValue or toggleValue action a path the screen writes

This script prints all of them for one file. It is plain Node with no dependencies — examples/docs/list-paths.mjs in the repository of the React package:

// node list-paths.mjs published.json
import { readFileSync } from 'node:fs'

const layout = JSON.parse(readFileSync(process.argv[2], 'utf8'))
const reads = new Set()
const writes = new Set()
const lists = new Set()
const INPUTS = ['TextField', 'Switch', 'Checkbox', 'ChoiceGroup']

function walk(value) {
  if (Array.isArray(value)) return value.forEach(walk)
  if (value === null || typeof value !== 'object') return
  // A bound value (`kind: "binding"`) or a condition (`visibleIf`, `disabledIf`, `styleWhen[].when`).
  if (typeof value.path === 'string' && (value.kind === 'binding' || 'op' in value)) reads.add(value.path)
  // An icon named by the data, and action parameters taken from the data.
  if (typeof value.iconPath === 'string') reads.add(value.iconPath)
  for (const path of Object.values(value.paramBindings ?? {})) reads.add(path)
  // A list drawn from a collection.
  if (value.type === 'Repeater' && typeof value.props?.source === 'string') lists.add(value.props.source)
  // What the screen writes: inputs, and buttons with a write action.
  if (INPUTS.includes(value.type) && typeof value.props?.value?.path === 'string') writes.add(value.props.value.path)
  if ((value.type === 'setValue' || value.type === 'toggleValue') && typeof value.params?.path === 'string') writes.add(value.params.path)
  Object.values(value).forEach(walk)
}

walk(layout.screens)
for (const [name, found] of Object.entries({ collections: lists, reads, writes })) console.log(`${name}: ${[...found].sort().join(', ')}`)

For the reward-catalog fixture of the contract (fixtures/reward-catalog-screens.json) it prints:

collections: items
reads: campaign.title, item.description, item.image, item.name, item.price, item.selectedId, user.isPremium, user.points
writes: item.selectedId

How to read the answer:

  • collections are the lists your app supplies, as arrays of records under these names.
  • reads are the paths the screens show or test. A path of the entity of a list (item.… for items) is read from the record of a row inside that list; on a screen outside the list it comes from your data, or from the row the screen was opened with.
  • A collection name among reads is a condition on the list itself — "show this block when tasks is empty".
  • writes are the paths your app receives in onWrite. They are also read: an input shows the value under its path.
  • Every name printed should be in the project's dictionary. One that is not means the document and the dictionary you hold are from different moments — ask for the current file.

The types of the values, the samples and the human names are not in the document: they are in the dictionary.

Editing the dictionary in the admin#

Open Project settings → Data dictionary → Open the dictionary.

Only an owner or an admin of the organization can change the dictionary. Other members see it read-only.

Standard and project dictionaries#

A new project has no dictionary of its own, so the Standard dictionary is in effect — a demonstration set (user, offer, task, taskGroup, item, campaign). It shows the shape; it is not your set of fields. Change it or import your own, then save — it becomes the Project dictionary. A project dictionary replaces the standard one entirely; the two are never merged.

Paths#

The Paths table has one row per value, with the columns Path, Type, Label and Sample. Add a path appends a row; Remove deletes one. Changing the type of a row resets a sample that no longer fits.

The editor points at the row to fix when something is wrong, for example:

  • "A path is entity.field: Latin letters, digits and _, parts joined by a dot."
  • "path is described twice."
  • "The sample must be a number."
  • "The sample must be a date, like 2026-01-15T12:00:00Z."

A dictionary needs at least one path.

Lists#

The Lists section has one block per collection: List path, Label, Entity of a record, and Sample records, JSON.

Sample records are an array of records as your backend sends them — field names without the entity:

[
  { "title": "Spend 500 with your card", "progress": 3 },
  { "title": "Invite a friend", "progress": 0 }
]

Fields left out of a record take the sample of their path. A null field is treated as left out. A field with no matching path, or with a value of the wrong type, is reported. Give the records different values: identical rows in a preview look like a rendering fault, not a list.

Sample records exist for the builder's preview only. They are not part of the downloaded file and are not sent in the webhook.

Import JSON#

Import JSON builds the dictionary for you. Paste JSON or choose a file. Two kinds of input are recognised automatically:

A sample of your backend response. Types come from the values, samples are the values themselves, and labels come from the field names (firstName → "First name"). Two shapes are understood:

  • the flat shape described above: "user.firstName": "Alex" plus lists of records;
  • an ordinary nested response: "user": { "firstName": "Alex" }.

For each list, the entity name is the list name in the singular (tasks → task), and up to three records are kept as sample records. A string is typed as date only if it is ISO 8601 with time and zone.

Some things cannot be worked out from a sample. They are left out and listed after the import, each with its reason:

Reason shown Example
a value without an entity; a path needs two parts, like user.firstName "total": 5 at the top level
the value is null, so its type is unknown "completedAt": null in every record
nested deeper than the dictionary describes an object inside an entity; a list on the third level
a list of values, not of records "tags": ["a", "b"]
the list is empty, so its fields are unknown "offers": []
the name has characters a path cannot have "first-name"
has different types in different places of the sample; the first one is kept total: 10 in one record and total: "10" in another
has no simple fields, so there is nothing to describe an empty object, or a list whose records hold only nested data

Send a sample in which lists are not empty and optional fields have values, and review the result: the labels and the singular entity names are a first draft.

A dictionary file — a data-contract.json downloaded earlier, or a bare { "paths": […], "collections": […] }. It is checked and loaded as is. A dictionary file carries no sample records for lists.

The import replaces what is on the page and saves nothing. Read the result, then save.

Saving, and what a change would break#

Press Save the dictionary. If the page has problems, nothing is saved and the places to fix are marked.

The dictionary is shared by all documents of the project, and the person editing it is not looking at any of them. So before the dictionary is written, every document of the project is checked against the new version. If the change would break a binding that is valid now — a removed path, a renamed one, a changed type — you see a warning:

This change breaks bindings. Documents affected: N.

followed by the affected documents and, for each, the screen and what is wrong there. You can go Back to editing or Save anyway. Saving anyway is legitimate: if a field really is gone from your backend, the dictionary must say so, and the documents must be fixed. An affected document cannot be published until its bindings are fixed.

Documents that could not be read are not checked, and the warning says how many.

Other things to know:

  • A document that is open in the builder takes the new dictionary when it is reopened.
  • If someone else saved the dictionary while you were editing, you are told, and nothing of yours is saved yet. Choose Open theirs or Save mine over it.
  • Leaving the page with unsaved changes asks for confirmation.
  • The dictionary has a size limit of 700,000 bytes. The usual cause of hitting it is an image embedded in a sample.

If the saved dictionary is damaged ("The dictionary cannot be read"), documents of the project are not opened until it is fixed: with a wrong dictionary every binding would look broken.

Download data-contract.json#

Download data-contract.json gives you the dictionary in effect as a file for your backend: the paths and collections under a contract key, together with the x-delivery rules and a JSON Schema of the shape (x-schema). Save pending changes first — the file holds the saved dictionary, not what is in the fields.

If you use the webhook, you do not need to download the file after every change: each delivery carries the current dictionary in its dataContract key. See Delivery options.

Checklist for your dictionary#

  • Field names are taken from the API response verbatim.
  • Every list names an entity, and that entity has paths.
  • Dates are ISO 8601 with time and zone.
  • Images are ready-made https addresses.
  • Samples are realistic.
  • Nothing is listed that the backend does not return.
  • The record identifier used for "list → details" navigation is a field of the record in the dictionary, and your details endpoint accepts the same identifier. The standard dictionary has no such field (item has no id): add one to yours.