Backend guide
This page is for the backend developer of a team that integrates HotLayout. It says what your backend has to do: which data to return, how to accept a publication, what to store, and how to serve the right version to each app. It ends with the questions worth settling before you write code.
The protocol is plain HTTP and JSON, so the language of your backend does not matter.
Related pages: index · delivery · data dictionary · compatibility
In one minute#
Normally every screen of an app is drawn in its code. Moving a badge or adding a block with terms takes a developer, a build and a release to the stores — weeks of waiting, while the product team wants to change promotions every month.
With HotLayout a screen is described by data, not by code. A manager assembles it in a visual builder and presses Publish; the app shows the new screen without a store update.
| Part | Runs | Role |
|---|---|---|
| Builder | in a browser | a manager assembles the screen; the output is a published document (JSON) |
| Your backend | on your servers, any stack | returns the user's data; stores and serves the published documents |
| Renderer | in your app (hotlayout_flutter, @hotlayout/react) |
reads the document, merges it with the data, draws the screen |
What your backend does#
| # | Task | Needed when |
|---|---|---|
| 1 | Return the data at the paths of the data dictionary | always — this is mostly what your API already does |
| 2 | Return the data that follows from a choice | the screens let a user pick or type something |
| 3 | Accept publications by webhook | you serve documents from your own servers — recommended for production, and the way for a web app |
| 4 | Store every published version and serve the one each app understands | together with task 3 |
| 5 | Cache correctly | together with task 3 |
Without tasks 3–5 a mobile app can read published documents from the direct address in our storage, and your backend only returns data. A web app cannot do that from a browser on your domain — see Serving a web app.
If something in the shape below is awkward for your stack, tell us at contact@hotlayout.com.
The main boundary: document ≠ data#
Two independent resources with different lifetimes.
The document is a form: a title here, a progress bar under it, a list under that. It is the same for all users, changes rarely, and is cached for a long time.
The data is what gets filled into the form for a particular person. It differs for everyone, changes constantly, and is cached briefly.
A document contains no values. Instead of "150 000" it holds a reference to a field:
{ "kind": "binding", "path": "task.title", "fallback": "—" }The app takes the document from one place and the data from another, and the renderer merges them on the device. A published document carries no user data, and with the webhook nothing about your users passes through us.
The data dictionary mirrors your real API#
The data dictionary is the list of everything a document is allowed to ask for. A new project starts with a demonstration dictionary that shows the shape; you replace it with your own set of fields in the admin. How it is edited, imported from a sample response and downloaded as data-contract.json is on the data dictionary page.
Field names in the dictionary must match the backend response verbatim. A "friendlier" name (task.name instead of what the API really returns) would cost a mapping layer between the response and the document — a third place where knowledge about fields lives, and the first one to drift. A manager has a readable name anyway: every path has a label.
The demonstration dictionary is taken from a typical set of endpoints:
| Endpoint | Collection in the dictionary | Entity |
|---|---|---|
GET /users/me/tasks |
tasks |
task |
GET /users/me/task-groups |
taskGroups |
taskGroup |
| the same, nested array | taskGroup.tasks |
task |
GET /catalog/items |
items |
item |
GET /offers |
offers |
offer |
Task groups are the case for which a list allows two levels of nesting: a group repeats in a list, and tasks repeat inside the group. A third level is not allowed.
The dictionary has two sections, and the difference between them matters.
paths — single values#
A flat map "path → value" for the current user:
{
"user.firstName": "Alex",
"user.points": 1250,
"campaign.title": "Autumn rewards"
}This is how values that belong to no list travel. Task fields (task.title and the rest) are possible in this form too — then they describe one task, for example on a details screen.
| Type | Send |
|---|---|
string |
a JSON string |
number |
a JSON number — never a string: "1850" gets no separators and no plural agreement |
boolean |
true / false |
date |
a moment: an ISO 8601 string with time and zone (2026-10-31T21:59:59.000Z or …+02:00) |
- A date without a zone is not a date. A screen shows a date in the user's language and the time zone of the user's device, so
2026-10-31would mean different moments to different users; the renderer shows such a string as it came. If a promotion ends at 23:59 Kyiv time, send exactly that moment. - A missing value is
nullor an absent key. Do not send an empty string in its place: every binding has its ownfallback, and the fallback decides what is shown. - Do not format. The document says how a value is written — group separators, a currency, a date, "days left", a plural word — and the renderer does it in the user's language. Send
1850, not"1,850".
collections — lists#
An array of records. Record keys carry no entity prefix:
{
"tasks": [
{ "id": "t-1", "title": "Spend 500 with your card", "progress": 3, "completed": false },
{ "id": "t-2", "title": "Set up a recurring payment", "progress": 1, "completed": true }
]
}That is what your list endpoint already returns — nothing has to change.
Why no prefix: the collection tasks declares that its records are the entity task, and inside the list the document reads task.title. The renderer adds the prefix when it puts a record into scope. A nested collection works the same way: a group carries its own tasks array.
Give every record a field that identifies it (id). A list row opens its details screen with its own record, and every action the app receives carries that record (item.id, item.name, …). When the user presses "Redeem", the app sends you the id from the record — so a record without one cannot be acted on. Treat that id as user input: check that this user may act on that record.
Three delivery rules#
They are written machine-readably in the x-delivery block of data-contract.json:
| Rule | Meaning |
|---|---|
maxCollectionItems: 50 |
The renderer draws at most 50 records. You may return more, but the rest is cut off; a longer list needs pagination at the product level. |
absentCollection: "empty" |
An absent collection counts as empty: the empty state of a list ("no active tasks") shows on an empty array and on an omitted key alike. Returning "tasks": [] is still the better habit. |
missingValue: "null-or-absent" |
See the note on fallback above. |
Data that depends on what the user chose#
A screen can let the user choose and type: a choice group, a switch, a text field, a button that writes a value. The renderer writes the value under a data path and tells the app (onWrite). It does not fetch anything, and it does not invent the data that follows from the choice. That data comes from your app — which usually means from you.
The typical case, and the one that goes wrong silently:
- A catalog screen has a choice group bound to
item.selectedId. - The user picks the second product and presses "Details".
- The details screen is bound to
item.name,item.price,item.image. - The app has not supplied these for the new choice — and the screen shows the fallback texts, or the product that was loaded first. Nothing fails and nothing is logged: a fallback is a legitimate state.
What it asks of the backend:
- An endpoint that returns one entity by the identifier the list carries —
GET /catalog/items/{id}— in the same field names as the list. The app calls it whenonWritereports a newitem.selectedIdand passes the answer to the renderer asitem.*. - Or return enough in the list itself, so the app can take the chosen record from what it already has.
- The identifier in the list and the one the details endpoint accepts are the same. If details are addressed by another key, put that key into the dictionary too.
The same holds for anything derived from input: a price that depends on a chosen plan, a list filtered by a switch, an error text for a typed phone number (the document has no validation rules — the error text is a string in your data).
A screen opened from a list row does not need this: the row carries its own record. The app side is in the quickstarts — React, Flutter.
Everything the app sends you after a write or an action is user input: the path and the action are named by a document, the value is typed by a person. Validate and authorize it like any other request.
Accept a publication#
Turn the webhook on in Project settings → Delivery; from then on every publication of a document is followed by a signed POST to your address. The request, the signature scheme, code samples for Node.js and .NET, the retries and the delivery log are on Delivery options. The short form:
- Read the body as bytes. Do not parse and re-serialize before the check: the signature is over the bytes as they arrived.
- Verify the signature — HMAC-SHA256 of
<t>.<raw body>with your secret, compared in constant time; reject atmore than five minutes away from your clock. Anyone can guess an address; only the signature proves the request is ours. - Store the document under the key
documentId+stamp. Storedocumentas it is, without transformations. A repeat with the same key is already done. - Answer
2xxwithin ten seconds. Do heavy work after responding.5xx,429, a dropped connection and a timeout are retried — three attempts in total; other4xxand redirects are not. - Take the dictionary from the same request. The body carries
dataContract— the project's data dictionary in effect — so a new field reaches you together with the screen that is bound to it. - Skip body keys you do not know. New keys are added without changing
version.
Every request carries user-agent: Hotlayout-Webhook/1. It is a constant, so a firewall rule can let it through — and it is not proof of origin: only the signature is.
The theme is not delivered by webhook. It is one file per project at its own direct address; read it on your server when you want to serve it yourself.
What to store#
One table of published versions. A row is never updated.
| Field | Type | Note |
|---|---|---|
documentId |
string |
from the webhook body; the key the app asks by |
stamp |
string |
identifies the publication; with documentId it is the primary key |
schemaVersion |
string |
semver from the document itself, e.g. 9.19.0. Keep it in its own column |
body |
JSON | document exactly as received |
receivedAt |
timestamp |
- Keep every version, not only the last one. The next section is why.
- Store
bodyas whole JSON. Breaking the screen tree into relational tables is not worth it: a migration on every contract change, slow queries, and all of that while a document is always read as a whole. The builder publishes documents of up to 1 MB. - A rollback is a new publication. A manager returns to an earlier version in the builder and publishes it; you receive it as a new
stamp. You do not need a rollback operation of your own. - Drafts are not yours to store. The builder keeps drafts and the publication history itself; your backend sees only what was published.
If you want a second barrier, validate document against screen-schema.json when you receive it (any JSON Schema validator will do — the file is in the contract folder). Validate with the schema of the document's own major; see below.
Endpoints for the app#
Three reads. The names are yours; the shape is what matters.
GET /v1/ui/documents/{documentId}?schemaVersion=9.19.0 published document
GET /v1/ui/theme?themeVersion=4.0.1 theme
GET /v1/ui/state user dataThe last one is your existing data endpoint under any name — or several of them. The document requires neither its address nor its shape: it names paths, and the app decides which of them are collected from where.
Serving a web app#
A web page on your domain cannot read a published document from our storage in the browser: the storage allows cross-origin reads only for the origins it was told about, and fetch fails with what looks like a network error. This is the reason the webhook is the way for a web app — the browser then reads from you.
- Serve the three endpoints from the same origin as the site, or allow the site's origin in your CORS settings.
- Do not require custom request headers on the document and theme endpoints if the app uses the package's own loaders: they send none, on purpose, to avoid a CORS preflight.
- Server-side rendering (Next.js) reads the document on the server, where CORS does not apply.
Details and the alternatives are in Reading from a browser.
Versions: the most important part of this guide#
Different renderer versions live in users' apps at the same time. Some have updated, some have not. So the document endpoint accepts schemaVersion — the version the app's renderer reads — and returns the newest document among those that version understands.
The rule: the major must match; a higher minor is readable.
| The app asks for | Published | Return |
|---|---|---|
9.19.0 |
9.19.0 |
exact match |
9.14.0 |
9.19.0 |
the same one: the renderer skips what it does not know and draws the rest by the compatibility policy |
8.20.0 |
9.0.0 |
the latest published with major 8; if there is none — 404, and the app shows its bundled copy |
This is why copies of all publications are kept. Otherwise, after a major update of the format, some users are left without a screen until they update the app from the store.
A practical tip: parse schemaVersion as semver when selecting; do not compare strings — 9.10.0 is newer than 9.9.0.
When the major changes#
The last row of the table is a real situation, not an example: schema 9.0.0 and theme 4.0.0 were major versions. For your backend the body of a document and of a theme stays opaque JSON. Four things around it matter.
- Published 8.x documents stay 8.x. A published version is immutable. A document becomes 9.x only when a manager opens it in the builder — which migrates the draft — and publishes it again. Until then an app with a 9.x renderer asking for
schemaVersion=9.0.0gets404and shows its bundled copy. Publish again first; release the app second. - An app with an 8.x renderer does not draw a 9.x document. After the document is published again, that app still asks for
8.xand must get the last 8.x version. It sees no newer edits until it updates from the store. - Do not migrate documents on the backend. The builder migrates a draft when it opens it. A second copy of that rule on your side would drift from the first.
- Validate with the schema of the document's own major. With
screen-schema.json9.x, validate only 9.x documents. Do not re-validate stored 8.x versions: they do not pass the new schema, and that is not a data error.
The theme follows the same rule with its own number: an app with a 3.x theme renderer gets the last published 3.x theme, an app with 4.x gets 4.x.
Two rules a JSON Schema does not check, in case you want a second barrier at publication:
- A token a document names is declared by the project theme. The schema checks only the shape of a name. A document with an undeclared token is still drawn, without that one property.
- Every theme of the set declares the same token names. Otherwise a screen looks right until the user switches on the dark theme.
The same move from the renderer's side is in moving from 8 to 9.
Cache#
- An
ETagon the document. A browser, and any HTTP client that keeps a cache, then asks withIf-None-Matchand gets304. Most checks change nothing — there is no reason to pay for them with traffic. - Ignore query parameters you do not know. To get past caches in between, the packages' loaders can add a unique parameter to the address (
_req=…); they send no custom request headers. Cache-Control: no-cache, notmax-age.no-cachemeans "check with me", not "do not cache". A longmax-ageturns a publication into "some time within the hour".- A CDN — yes, with invalidation when a publication arrives.
- Data and document are cached differently: the document for a long time and shared by everyone, the data briefly and per person. One endpoint for both would make either the data stale or the document inefficient.
Questions to settle before you write code#
1. Values, not ready-made strings#
The API returns numbers and moments: progress: 3, target: 5, price: 1850.5, endsAt: "2026-10-31T23:59:59Z". The screen needs "3 / 5", "1,850.50 USD", "12 days left". The document composes these: a text can be a sentence with named places, a binding says how its value is written, and the renderer picks the plural word in the user's language. So:
- return raw values, in the types of the table above;
- do not return
progressLabelordeadlineLabelnext to them — two sources for one text drift; - what a document cannot write today — hours and minutes until an event, compact notation (
1.2K) — is the exception: return that as a ready string.
See Data dictionary for the formats.
2. Numeric enumerations: what does 0 mean?#
If statuses arrive as numbers (status: 1), a condition on them is possible, but the manager has to guess what 1 means. Return the status as a string from a fixed list ("locked" | "inProgress" | "completed"), as the demonstration dictionary does — then a condition is readable by eye.
3. A storage key or a ready-made address?#
If an image arrives as a key in a storage, someone has to build an address from it. The contract asks for a ready-made https address in the data: the renderer does not build addresses, because the rule for building one would become yet another contract. The web renderer draws only absolute https: addresses unless the app decides otherwise.
4. Identifiers#
The identifier a list record carries must be a field of the record in the dictionary, and the endpoints that act on a record (details, redeem) must accept that same identifier — see Data that depends on what the user chose.
5. Entry points#
A document can name public entry points — stable names of screens for pushes, banners and URLs. Their names become part of what your app and your campaigns refer to, so agree on them early: renaming one later breaks links.
6. Retention#
How long to keep copies of publications. They are immutable and cheap, but your organization most likely has a retention policy — and old app versions need the last document of their major for as long as they are in use.
Related pages#
- Delivery options — the webhook and the direct address in detail.
- Data dictionary — the dictionary and its editor in the admin.
- Compatibility — version rules for renderers.
- Widget reference — what a document can contain.
- Theme, Localization, Changelog, Overview.