Skip to content
HotLayout Docs

Self-hosted storage

This page is for the team that runs the HotLayout editor inside its own network and keeps the documents on its own servers. In this mode the editor reads and writes JSON through an HTTP API that you provide. The editor makes no request to HotLayout servers or to Google.

Related pages: index · embedding · backend guide · network requirements

What the mode is#

You put the files of the editor on your own host and point it at the address of your API. Everything the editor keeps — drafts, published documents, version snapshots, the theme, images — goes to that API. Your side provides:

  • the API described below, over your database or file storage;
  • the sign-in of your people: the editor uses the token your page hands it through tokenProvider;
  • the admin around the editor: lists of projects and documents, access control, activity log, updates.

Get the editor files#

HotLayout supplies the editor files together with your licence key. They are static files, built for your installation: the module hotlayout-builder.js and the folder chunks/ next to it. Nothing in them is generated per request.

  1. Unpack the archive to a folder of your web server, for example /assets/hotlayout/, keeping chunks/ beside hotlayout-builder.js. The module loads its parts by relative address.
  2. Serve .js files as text/javascript. The file names in chunks/ carry a content hash, so they may be cached for a long time. Give hotlayout-builder.js itself a short cache, or no cache, so that an update reaches the page.
  3. Import the module from your address, not from the HotLayout one, and register the element:
import { defineHotlayoutBuilder } from 'https://builder.bank.example/assets/hotlayout/hotlayout-builder.js'

defineHotlayoutBuilder()

The page embedding describes the same element and its properties for the hosted case, where the module is loaded from hotlayout.com; in this mode that address is never used. Allow your own origin in the content security policy for scripts, and nothing else for the editor.

An update is a new archive: replace the folder, and the next page load uses it.

The documents themselves are the same JSON as everywhere else, so a screen built in this mode is read by the same renderers.

Connect the editor#

Set the storage-url attribute to the base address of your API, together with the address of the document, and tell the editor who is working:

<hotlayout-builder
  storage-url="https://builder-api.bank.example/hotlayout"
  org-id="your-org-id"
  project-id="your-project-id"
  document-id="your-document-id"
  access="publish"
></hotlayout-builder>
const builder = document.querySelector('hotlayout-builder')
builder.user = { id: 'u-1042', name: 'Olena B.', email: 'olena@bank.example' }
builder.tokenProvider = () => Promise.resolve(currentSessionToken()) // optional: leave it out when your API trusts a session cookie
  • A relative address (/api/hotlayout) is read against the base address of your page.
  • The address is a base path only: no query (?…), no fragment (#…) and no user name or password in it. Every request path is appended to it, so with a query the paths would land in the query, and with a fragment the browser would not send them at all. The editor refuses such an address without sending a single request and says why in the console.
  • The token is whatever your tokenProvider returns. The editor sends it unchanged as Authorization: Bearer <token> and never looks inside. Without a tokenProvider no Authorization header is sent at all, which suits an API that trusts your own session cookie.
  • org-id and project-id are plain identifiers of your choice: they become the first two segments of every request path.
  • The access attribute only decides what the editor draws (view, edit, publish). Enforce the same rule on the API: a page can be changed from the browser console.

If your side prefers to do the requests itself — through an existing client with its own retries and error handling — assign an object to the storage property instead. The property wins when both are set.

interface EditorStorage {
  loadDraft(scope, documentId): Promise<DraftLoadResult>
  saveDraft(scope, document, request): Promise<DraftSaveResult>
  publish(scope, document, options): Promise<SaveResult>
  loadPublished(scope, documentId): Promise<LoadResult>
  publishedUrl(scope, documentId): string | undefined
  listVersions(scope, documentId): Promise<VersionListResult>
  loadVersion(scope, documentId, stamp): Promise<LoadResult>
  listPublications(scope, documentId): Promise<PublicationListResult>
  listDocuments(scope): Promise<DocumentRecordsResult>
  loadTheme(scope): Promise<ThemeLoadResult>
  readThemeVersion(scope): Promise<ThemeVersionAnswer>
  publishTheme(scope, contract, options): Promise<ThemePublishResult>
  listMedia(scope): Promise<readonly MediaItem[]>
  uploadMedia(scope, upload): Promise<UploadResult>
  uploadSvg?(scope, upload): Promise<UploadResult>
  listScenarios?(scope): Promise<ScenarioListResult>
  saveScenario?(scope, scenario, base?): Promise<ScenarioWriteResult>
  deleteScenario?(scope, id, base): Promise<ScenarioWriteResult>
  isMediaUrl(scope, url): boolean
}

scope is { project: { orgId, projectId }, token: () => Promise<string>, user?: { id, email?, name? } } — user is what the host set in the user property ({ id: 'anonymous' } when it set none). Every method except listMedia answers with a value, never an exception: { ok: true, … } or { ok: false, failure }, where failure.reason is one of not_found, unauthorized, conflict, stale, offline, too_large, unreadable, storage_error, no_token, rejected, timeout. listMedia throws an Error whose message is shown to the person as it is. An object without a required method is refused, with the missing names in the console. The sample server below shows each method in a form your team can copy.

The HTTP API#

Every path starts with the base address you gave in storage-url, followed by /{orgId}/{projectId}/. The table lists the rest of the path. All bodies are JSON in UTF-8 unless noted. Every request carries Authorization: Bearer <token>.

The editor… Request Success Failure
lists the documents of the project GET documents 200 { "documents": [{ "documentId", "name" }] }
opens the draft GET documents/{id}/draft 200, body is the document, header ETag 404 there is no draft
saves the draft PUT documents/{id}/draft, body is the document 200 { "version" }, header ETag 409 { "error": "conflict", "current" }, 409 { "error": "stale" } an older revision of the same session, 413 too large
publishes POST documents/{id}/publish, body is the document 200 { "stamp" } 409 { "error": "stale" }
reads what is published GET documents/{id}/published 200, body is the document 404
lists the versions GET documents/{id}/versions 200 { "versions": [{ "stamp", "bytes"? }] }, newest first
reads one version GET documents/{id}/versions/{stamp} 200, body is the document 404
asks who published GET documents/{id}/publications 200 { "publications": [{ "stamp", "by", "byEmail"?, "at" }] }
reads the theme GET theme 200, body is the theme, header ETag 404 nothing published
asks the theme version HEAD theme 200 with ETag 404
publishes the theme PUT theme, body is the theme 200 { "version" }, header ETag 409 { "error": "stale" }
lists images GET media 200 { "items": [{ "name", "url" }] }
lists the named data scenarios GET scenarios 200 { "scenarios": [{ "id", "version", "value" }] }
reads one scenario GET scenarios/{id} 200, body is the stored record, header ETag 404
saves a scenario PUT scenarios/{id}, body is { "format", "name", "base", "picksText" } 200 { "version" }, header ETag 409 { "error": "stale" }
deletes a scenario DELETE scenarios/{id} 200 404 already gone (the editor treats it as done), 409 { "error": "stale" }
uploads an image PUT media/{name}, body is the file, Content-Type of the image 201 { "item": { "name", "url" } } 413, 415, 409 the name is taken

Rules that apply to all requests:

  • The editor waits 20 seconds for the whole answer to an ordinary request — the status, the headers and the body (120 seconds for an image upload). A request whose answer is not complete in time is cancelled and treated like a lost connection: the editor shows that saving failed and retries. Answer within the limit, or fail fast with a status code.
  • An answer body is at most 1 MiB. The editor reads it as a stream and stops at the limit: a larger answer is treated as "too large" and is not parsed.
  • No redirects. The editor does not follow 3xx answers: a redirect is a failure (the browser reports it like a lost connection), never a repeat of the request — with the document and the person's name — on another address. Serve the API at the address you put in storage-url.
  • 401 and 403 mean "no access"; the editor shows its access message. 404 means "not there" (for a draft: "not saved yet" — a normal state). Any other error status is shown with its code, so keep codes meaningful.
  • An error body is { "error": "<code>" }. Only the codes named in the table matter to the editor.
  • Identifiers (orgId, projectId, documentId) match ^(?!\.+$)[A-Za-z0-9._-]{1,64}$: letters, digits, ., _ and -, at most 64 of them, and not made of dots only — not . or ... Refuse anything else before it reaches a file path or a query.
  • A document is stored as received. Do not reorder or rewrite it. Its size limit is 1 MiB; answer 413 above it.
  • stamp is the UTC time of the publication with : and . replaced by -, for example 2026-10-14T09-30-00-123Z. Stamps must sort as text in time order. Two publications within one millisecond must still get different stamps.
  • The published document, the theme and the images are read by the apps of your customers, not only by the editor. How you protect those reads is your decision; the sample server leaves them to your authentication hook.

Versions and conflicts#

The editor never compares anything except for equality, so a version is any opaque string your API chooses: a counter, a hash of the content, a row revision.

  • Every successful GET and PUT of a draft or of the theme returns the current version in the ETag header. The editor refuses to open a draft that arrives without it, because without a version nobody could detect a clash.

  • A write names the version it was based on:

    • If-Match: "<version>" — the draft was last seen at this version;
    • If-None-Match: * — the editor believes the file does not exist yet;
    • neither header — no check ("keep mine", import, rollback).
  • When the stored version differs, do not write. Answer 409 with { "error": "conflict", "current": "<version>" }, where current is the stored version, or null if there is no draft. The editor then offers the person to open the other version or keep theirs.

  • The one exception: the write carries X-Hotlayout-Writer: <random id of this editor session> and X-Hotlayout-Writer-Revision: <integer> — a number that grows with every revision of the document in this session; a retry of the same revision carries the same number. If the stored draft was last written by the same writer, accept the write even when the base is behind, but only forward: nobody else wrote in between, yet an older revision of the same session may arrive after a newer one (a retry after a timeout, a save sent again when the page closes). Store the writer and the revision next to the draft, and when the base is behind and the writer is the same:

    • the revision is greater than the stored one — write;
    • the revision is equal — a repeat: answer 200 with the stored ETag and write nothing if the body is the same, otherwise 409 { "error": "stale" };
    • the revision is smaller, or either number is missing — do not write: 409 { "error": "stale" } for a smaller one, 409 { "error": "conflict", "current" } when there is no number to compare.

    Without the exception a second save that left before the answer to the first one would conflict with itself. The editor treats stale on its own draft write as "a newer revision of mine is already there" and shows no conflict. A storage object receives the same two values as request.writer and request.writerRevision and follows the same rule.

  • A deliberate replacement of the draft adds X-Hotlayout-Replace-Reason: rollback | import | takeTheirs and, for a rollback, X-Hotlayout-Replace-From: <stamp>. Record them if you keep an activity log; otherwise ignore them.

  • Publishing takes the same If-Match / If-None-Match, naming the version of the draft the editor saw. If the draft has changed since, answer 409 with { "error": "stale" } and publish nothing: the page is out of date, and publishing from it would roll the apps back.

  • A publication does three things at once, and they must succeed or fail together: the new published document, the version snapshot under its stamp, and the record of who published (by from your token, byEmail if you know it, at as an ISO 8601 time).

  • The theme works like the draft: ETag, If-Match / If-None-Match: *, 409 with { "error": "stale" }. A request with X-Hotlayout-Overwrite: true skips the check — the person has confirmed "publish mine anyway".

Named data scenarios#

A scenario is a saved choice of sample data that a person can reopen in the preview. It is stored like the theme: one record per scenario, an opaque version in ETag, and If-Match / If-None-Match: * on writes.

  • The body of a PUT has four fields: format (the integer 1), name, base and picksText (a JSON text). Store them as received and return them as they are in GET scenarios and GET scenarios/{id}; a record whose format is higher than the editor knows is shown as unavailable and never rewritten.
  • Add your own accounting to the record when you store it: createdAt, createdBy, actor, actedAt, all strings, taken from your verified token. The editor reads them back and never sends them.
  • If-None-Match: * means "create": answer 409 { "error": "stale" } if the id is taken. If-Match on a write or a delete names the version the person saw; answer 409 { "error": "stale" } when it differs. The editor then reads the record with GET scenarios/{id} and offers the person the choice.
  • scenarios is optional in the storage object too (listScenarios, saveScenario, deleteScenario, all three or none). Without them scenarios live only until the page is reloaded, and the editor says so next to the Save button.

Cross-origin pages#

When the editor page and the API are on different origins, the API must answer the preflight and expose the version header:

Access-Control-Allow-Origin: https://builder.bank.example
Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, If-None-Match, X-Hotlayout-Writer, X-Hotlayout-Writer-Revision, X-Hotlayout-Replace-Reason, X-Hotlayout-Replace-From, X-Hotlayout-Overwrite, X-Hotlayout-User
Access-Control-Allow-Methods: GET, HEAD, PUT, POST, DELETE, OPTIONS
Access-Control-Expose-Headers: ETag

Without Access-Control-Expose-Headers: ETag the browser hides the version from the editor, and the draft does not open. This is the most common reason for "the document does not open" in a new installation.

Images#

The editor checks a file before it sends it (type by its first bytes, size, a free name), so your API gets PNG, JPEG or WebP files with a free name. It still must protect itself: check the type and the size, and refuse a name that is already taken with 409.

The address of an image is chosen by your API. Every item of GET media and every successful upload carries a url; the editor puts exactly that address into the document and never builds one. Make it an address the apps of your customers can reach, and stable: it stays in published documents. The editor recognises a library image by the address: either …/{orgId}/{projectId}/media/ of your API, or the folder of any address your API has returned in GET media or in an upload answer. So images served from another host, such as a CDN, are recognised once the library has been listed.

SVG#

An SVG file is a document that can carry scripts, so it must never be stored as received. The editor sends it to the same PUT media/{name} with Content-Type: image/svg+xml, and your API chooses one of three answers:

Answer When
201 with the item you sanitised the file and stored the clean result
422 { "error": "svg_not_supported" } your installation does not accept SVG; the person is told to use PNG or WebP
422 { "error": "svg_rejected", "why": "<reason>" } the file is not acceptable; why is one of not-svg, too-large, too-complex, doctype, embedded-raster, empty, no-size, name-taken, internal

The sample server answers svg_not_supported. Add a sanitiser of your own before you accept SVG.

Sign-in and the person#

Setting storage-url (or the storage property) switches the editor into the bank mode, and in it the editor has no sign-in of its own: no form, no request to identitytoolkit.googleapis.com, securetoken.googleapis.com or hotlayout.com, nothing kept in localStorage. Your page signs the person in; the editor only needs two things from it.

  • Who is working — the user property, { id: string, email?: string, name?: string }. It signs the edits: the REST adapter sends it on every request as the X-Hotlayout-User header (percent-encoded JSON; the sample server decodes it), and a storage object receives it as scope.user. Without it the person is { id: 'anonymous' } and the console says so once. The header is only what the page claims: take the real author from your verified token or session and use the header at most as a display name.
  • A token — the tokenProvider property, optional. Whatever it returns is an opaque string: the editor sends it as Authorization: Bearer <token> (REST) or hands it to your storage object in scope.token, and never reads what is inside. If the provider fails or returns an empty string, the editor makes no request and shows the sign-in problem. Without a provider the REST adapter sends no Authorization header.

Two more things to know:

  • sessionProvider has no effect. The ticket exchange talks to HotLayout servers, so the element ignores the property and writes one sentence to the console.
  • The access attribute (view, edit, publish) works as everywhere: it only decides what the editor draws, and publishing is drawn only on publish. Enforce the same rule on your API.

What does not work in this mode#

  • The HotLayout admin (organizations, projects, members, invitations, plans) is not part of it. Your own admin lists the documents and opens the editor with the three address attributes.
  • The HotLayout activity log is not written. If you need a log, write it from your API: every draft write and publication passes through it.
  • Webhook delivery is not used. Your API receives the published document itself, so it is the delivery: serve it to the apps from your own servers, as described in the backend guide.
  • The editor's own sign-in and the ticket exchange of sessionProvider are off in this mode: see Sign-in and the person.

Licence key#

In this mode the editor starts only with a licence key from HotLayout. The key is one line of text that starts with hl_lic_. Put it into the licence-key attribute, or assign it to the licenceKey property:

<hotlayout-builder
  storage-url="https://builder-api.bank.example/hotlayout"
  licence-key="hl_lic_eyJsaWNlbmNlSWQiOi…"
  org-id="your-org-id"
  project-id="your-project-id"
  document-id="your-document-id"
></hotlayout-builder>

The key is not a secret. It names your company, the addresses where the editor may run, the end date, the enabled features and the number of people in the agreement, and it carries a signature that only HotLayout can make. The editor checks it in the browser, with no request anywhere: when the page opens and once an hour after that. Any change to the text, even one letter, breaks the signature.

The addresses are exact origins, compared with the address of the page that shows the editor: scheme, host and port. https://builder.bank.example and http://builder.bank.example are two different origins, and so are :443 and :8443. Tell HotLayout every origin where your people open the editor, including test and standby installations.

What the people see:

State of the key What happens
no key, a damaged key, a key for other addresses, a key signed with a key this version of the editor does not know the editor does not open: the page says No valid HotLayout licence, the licence number if it can be read, and the reason. Your storage is not read
valid, more than 30 days left the editor works with no marks
30 days or less left a bar above the editor: the licence expires on a date — contact HotLayout. Everyone sees it
expired, up to 14 days ago the same bar, more prominent, with the date when publishing stops
expired more than 14 days ago people can keep editing and drafts are saved, but Publish is greyed out with the reason in words. What is already published and your apps are not affected

The editor uses the clock of the computer it runs on. The browser console tells your developers what is wrong with a key that is not accepted, in English.

Renewing means replacing the line: HotLayout sends a new key with a new end date, you put it into the attribute instead of the old one, and the editor uses it on the next page load. Nothing is reinstalled. A key works with every version of the editor released before its end date.

Licensing terms are part of the agreement with HotLayout.

The sample server#

examples/self-hosted-server/ in the repository of the editor is a complete implementation of this API in one file of Node.js, with no dependencies, over a folder on disk. Read it as a specification you can run:

cd examples/self-hosted-server
# ALLOW_NO_TOKEN=1 lets a page without a tokenProvider work; drop it when your page hands over a token
ALLOW_NO_TOKEN=1 DATA_DIR=./data ALLOWED_ORIGINS=http://localhost:5173 node server.mjs
Variable Meaning Default
PORT port to listen on 8787
ALLOW_NO_TOKEN set to 1 to accept requests without an Authorization header (a host that gave no tokenProvider) requests without a token get 401
DATA_DIR folder for the files ./data
ALLOWED_ORIGINS comma-separated origins of the editor page; * allows all * (with a warning)
API_TOKEN if set, the only token the server accepts any non-empty token
PUBLIC_BASE_URL the start of image addresses in answers the Host of the request

What your team must replace, each marked BANK: in the code:

  1. authenticate() accepts any token. Verify your own session token and return the person.
  2. The folder on disk is the storage. Move it to your database or object storage, keeping the semantics: atomic writes, the version check and the draft write in one step.
  3. The SVG refusal stays until you add a sanitiser.
  4. The public reads of images exist because an <img> tag cannot send a token. Decide how your images are served.

The same cases run against this server and against an in-memory storage in the editor's test suite, so it is a safe reference for the behaviour above.