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-14or 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. daysUntilis 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
bindingis 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
pluralis 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 fromvaluesis 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.selectedIdHow to read the answer:
collectionsare the lists your app supplies, as arrays of records under these names.readsare the paths the screens show or test. A path of the entity of a list (item.…foritems) 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
readsis a condition on the list itself — "show this block whentasksis empty". writesare the paths your app receives inonWrite. 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
httpsaddresses. - 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 (
itemhas noid): add one to yours.
Related pages#
- Backend guide — the full picture for a backend developer.
- Delivery options — the dictionary inside the webhook body.
- Widget reference — bindings, conditions and
formaton each widget. - Compatibility, Changelog, Overview.