Delivery options
This page is for the backend developer and for whoever reviews the integration for security. It answers one question: how a published document gets from HotLayout to your app.
Watch: Where your users' data lives — HotLayout for banks and fintech (2 min).
There are two ways: a signed webhook to your backend, and the direct address of the published file in our storage. Pick with the table, then read the section of the way you chose.
| Webhook | Direct address | |
|---|---|---|
| Who fetches the document | we send it to your backend; your app reads from your servers | your app reads the file from our storage |
| Authentication | every request is signed with a per-project secret | none — the file is public to anyone who has the address |
| What you build | an HTTPS endpoint that verifies, stores and serves | nothing on the backend |
| Version selection per renderer version | yours to implement (see Backend guide) | none: the address always returns the latest publication |
| A web app in a browser | reads from your backend, like any of your APIs | cannot read it from your domain — see Reading from a browser |
| Good for | production apps, and every web app | prototypes, small mobile apps, server-side reads |
Both are set up in the admin under Project settings → Delivery. They are not exclusive: a document is published to our storage in either case, and the webhook is sent in addition.
What a developer asks for#
The person who writes the app often has no access to the admin. Whoever built the screen can hand over everything the integration needs:
| What | Where it is in the admin |
|---|---|
| The address of the published document — or three ids: organization, project, document | The list of documents, Copy address for the app on the row of a published document. The document id stands under the document's name in the same list; the organization and project ids are in the address template under Project settings → Delivery → Direct address. |
| The address of the published theme | Project settings → Delivery → Address of the published theme. |
The data dictionary, data-contract.json |
Project settings → Data dictionary → Open the dictionary → Download data-contract.json. With the webhook on, every delivery carries it too. |
| A copy of the document and of the theme to bundle with a mobile app as the offline fallback | Nothing to export: the two addresses return exactly these files. Open them and save the answers. |
With the three ids alone a developer can write both addresses — see Direct address. Which values one document needs from the app is in the document: What a document reads.
Webhook#
When the webhook is on, every publication of a document is followed by a POST to an address on your backend. You store the document and serve it to your app from your own servers. Our server never sees your users' data.
Turn it on#
- Open Project settings → Delivery → Webhook. Only an owner or an admin of the organization can set up a webhook.
- Enter the Webhook address — an
httpsaddress on your backend that accepts aPOSTrequest — and press Turn on webhook. - Copy the signing secret. It is shown once: it stays on the page until you press I have saved it, and is gone when the tab is closed. Nobody can read it again, including you.
The secret is a string of the form whsec_ followed by 64 hexadecimal characters. Use the whole string, prefix included, as the HMAC key.
After saving, the admin shows only the host of the address ("Every publication is sent to api.example.com."), not its path or query.
Replace address and secret always issues a new secret; the old one stops working at that moment. A lost secret cannot be recovered, only replaced. Turn off destroys the secret: turning the webhook back on issues a new one.
Accepted addresses#
The address is checked when you save it and again before every request.
| Rule | Detail |
|---|---|
| Scheme | https only |
| Port | the standard HTTPS port only (no port, or 443) |
| Credentials | no login or password in the address — requests are signed instead |
| Length | at most 2048 characters |
| Host | a host name made of letters, digits, dots and hyphens. An IPv6 literal is not accepted |
| DNS | the name must resolve to an IPv4 address (an A record). We do not connect over IPv6: only A records are looked up, so a name with AAAA records alone does not resolve |
| Network | every address the name resolves to must be public. Private, loopback, link-local, carrier-grade NAT, documentation, benchmark, multicast and reserved ranges are rejected |
| Redirects | not followed. A 301 or 302 answer counts as "not delivered" |
The name is resolved before each attempt, and the connection goes to the address that was just checked.
The request#
POST <your address>
content-type: application/json; charset=utf-8
x-webhook-event: document.published
x-webhook-delivery: <documentId>/<stamp>
x-webhook-signature: t=<seconds>,v1=<hex>
user-agent: Hotlayout-Webhook/1| Header | Meaning |
|---|---|
x-webhook-event |
the event name. There is one event today: document.published |
x-webhook-delivery |
<documentId>/<stamp>. The same on every attempt for one publication — use it to recognise a repeat |
x-webhook-signature |
t is the Unix time of this attempt in seconds; v1 is the HMAC-SHA256 digest in lowercase hex (64 characters) |
user-agent |
always Hotlayout-Webhook/1. A constant, so a firewall rule can let it through; it is not proof of origin — only the signature is |
The body:
{
"version": 1,
"event": "document.published",
"deliveredAt": "2026-10-07T20:00:05.000Z",
"orgId": "…",
"projectId": "…",
"documentId": "reward-catalog",
"stamp": "2026-10-07T20-00-00-000Z",
"document": { "schemaVersion": "9.19.0", "…": "…" },
"dataContract": {
"paths": [{ "path": "client.name", "type": "string", "label": "Client", "sampleValue": "Olena" }],
"collections": [{ "path": "orders", "label": "Orders", "item": "order" }]
}
}| Key | Meaning |
|---|---|
version |
version of the body shape. It changes only when a change would break a receiver |
event |
the same value as the x-webhook-event header |
deliveredAt |
the time of this attempt, ISO 8601 in UTC |
orgId, projectId |
the organization and the project the document belongs to |
documentId |
the document |
stamp |
identifies this publication: the publication time in UTC, in the form YYYY-MM-DDTHH-MM-SS-mmmZ. Together with documentId it is the key of the version |
document |
the published document as is. Store it without transformations |
dataContract |
the project's data dictionary in effect at the moment of delivery, or null |
dataContract has the same paths and collections as the data-contract.json file. Your backend serves values at these paths, so a new dictionary field reaches you in the same request as the screen that is bound to it. The key is always present. null means the project has no dictionary of its own (the standard one is in effect) or the saved one could not be read. Sample records of lists are not included.
The key was added without changing version. Skip body keys you do not know — future keys will be added the same way.
Only document publications are sent. The theme is not delivered by webhook; see Theme.
Verify the signature#
Anyone can guess a webhook address. Only the signature proves that the request is ours, so do not accept a webhook without checking it.
- Take
tandv1from thex-webhook-signatureheader. - Compute HMAC-SHA256 of
<t>.<raw body>with your secret as the key. - Compare the result with
v1in constant time. - Reject the request if
tis more than five minutes away from your clock. Without this, an intercepted request could be replayed later.
Use the raw body — the bytes as they arrived. Do not parse the JSON and serialize it again: a serializer changes whitespace or key order, and the signature will not match.
The scheme is the same as Stripe's webhook signatures. If you already have code that verifies those, it will fit.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
/** `rawBody` is the request body exactly as received, before any JSON parsing. */
export function isOurs(rawBody: Buffer, header: string, secret: string, nowMs = Date.now()): boolean {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header)
if (match === null) return false
const [, seconds, digest] = match
if (Math.abs(nowMs / 1000 - Number(seconds)) > 300) return false
const expected = createHmac('sha256', secret).update(`${seconds}.`).update(rawBody).digest()
// Both buffers are 32 bytes: the pattern above guarantees 64 hex characters.
return timingSafeEqual(expected, Buffer.from(digest, 'hex'))
}With Express, read the body as bytes on this route — for example express.raw({ type: 'application/json' }) — so that req.body is a Buffer. Parse the JSON only after the check passes.
C# (.NET)
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
public static class WebhookSignature
{
private static readonly Regex Shape = new("^t=(\\d+),v1=([0-9a-f]{64})$", RegexOptions.CultureInvariant);
/// <param name="rawBody">The request body exactly as received, before any JSON parsing.</param>
public static bool IsOurs(byte[] rawBody, string? header, string secret, DateTimeOffset now)
{
if (header is null) return false;
var match = Shape.Match(header);
if (!match.Success) return false;
var t = match.Groups[1].Value;
if (!long.TryParse(t, out var seconds)) return false;
if (Math.Abs(now.ToUnixTimeSeconds() - seconds) > 300) return false;
// Signed bytes: "<t>." followed by the raw body.
var prefix = Encoding.ASCII.GetBytes(t + ".");
var signed = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
var received = Convert.FromHexString(match.Groups[2].Value);
return CryptographicOperations.FixedTimeEquals(expected, received);
}
}In ASP.NET Core, read the bytes yourself and do not bind the body to a model on this endpoint:
app.MapPost("/hooks/hotlayout", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var rawBody = buffer.ToArray();
var header = request.Headers["x-webhook-signature"].ToString();
if (!WebhookSignature.IsOurs(rawBody, header, webhookSecret, DateTimeOffset.UtcNow))
return Results.Unauthorized();
// Verified. Store rawBody (or the parsed document) keyed by documentId + stamp, then answer.
return Results.Ok();
});HMACSHA256.HashData and Convert.FromHexString need .NET 6 or later.
Responses and retries#
One publication gets at most three attempts. Each attempt waits up to ten seconds for your answer. The pauses between attempts are one second, then five seconds.
| Your endpoint | What we do |
|---|---|
answers 2xx within ten seconds |
delivered; no further attempts |
answers 5xx, 429, 408 or 425 |
retry |
| drops the connection, fails TLS, or does not answer in time | retry |
answers any other 4xx |
no retry: a second attempt would get the same answer |
answers 3xx |
not delivered, no retry: redirects are not followed |
| host name does not resolve | retry |
| host name resolves to a non-public address | not delivered, no retry |
Practical consequences:
- Answer fast. Verify, store, respond; do heavy work after the response.
- Expect repeats. If your
2xxis lost on the way, the same publication arrives again with the samex-webhook-delivery. Treat it as already done: the keydocumentId+stampis the same. - Each attempt is signed anew.
t,deliveredAtand thereforev1differ between attempts;x-webhook-deliverydoes not. - After the third failed attempt the publication stays undelivered. There is no later automatic redelivery.
A failed delivery does not undo the publication. The document is published in our storage regardless, and you can fetch it at the direct address.
There is no "send again" action for an undelivered publication. To get a missed publication, publish the document again (a new publication, a new stamp), or read the direct address once.
Rate limit#
At most 30 deliveries per project in a ten-minute window. A publication over the limit is not sent and appears in the log as not delivered, reason rate-limited. The document itself is still published.
Delivery log#
Under the webhook settings the admin shows Recent deliveries — the latest 20 for the project. The list does not refresh on its own; press Refresh. A row appears a few seconds after a publication.
Each row shows a status, the document ID, the number of attempts (when more than one), the time, and for a failure the reason.
| Status | Meaning |
|---|---|
| Delivered | your endpoint answered 2xx |
| Not delivered | all attempts failed, or the request was never sent |
| On its way | delivery has started and its outcome is not recorded yet |
| Reason shown | Meaning |
|---|---|
| The receiver answered status. | your endpoint answered with this non-2xx code |
| The receiver could not be reached. | dropped connection, TLS failure, or timeout |
| The host name does not resolve to any address. | DNS returned nothing |
| The address leads into a private network, so we do not send there. | a resolved address is not a public IPv4 address |
| The address is not accepted. | the saved address fails the address rules |
| The saved address does not lead to the host shown here: replace the address and secret. | the stored address and the displayed host disagree |
| Too many publications in a short time: this one was not sent. The document itself is published. | the rate limit |
| There is no signing secret: replace the address and secret. | nothing is sent unsigned |
| The published document could not be read. | the published file was missing or not JSON at delivery time |
| Something failed on our side. | an internal error |
Webhook requests do not come from a fixed set of IP addresses, so they cannot be allowlisted by address. The signature is what proves a request is ours.
Direct address#
Every published document is also a file in our storage. Your app can read it with a plain GET, without a token.
The address is shown in Project settings → Delivery → Direct address, with a copy button. It is a template: the placeholder DOCUMENT-ID is highlighted, and the link does not work until you replace it with the ID of a document, which is shown under the document's name in the list of documents. For a ready address, use Copy address for the app on the row of a published document in the list — it is offered only for documents that have been published. The row has a second action, Open prototype: that one is for people, not for code — it opens the published document as a clickable screen in a new tab (the same link stands above the editor).
The file lives at this path inside the storage:
orgs/{orgId}/projects/{projectId}/documents/{documentId}/published.jsonand the full address has this form (the path is URL-encoded as a single segment, so / becomes %2F, and alt=media is required — without it the address returns file metadata instead of the document):
https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Fdocuments%2F<documentId>%2Fpublished.json?alt=mediaThe host and the bucket (ui-builder-dd4af.firebasestorage.app) are the same for every project. Replace <orgId>, <projectId> and <documentId> with your three ids and change nothing else. The address copied in the admin is this same string, ready-made.
The published theme of the project has one address per project:
https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2Fprojects%2F<projectId>%2Ftheme%2Fpublished.json?alt=mediaWhat you get:
- The latest publication only. The file is replaced on every publication. There is no
schemaVersionparameter and no per-version selection; if you need to serve older apps a document of an older contract major, use the webhook and your own storage (see "Versions" in the Backend guide). - The document as published, the same JSON the webhook carries in
document. - The published theme of the project is available the same way, at
orgs/{orgId}/projects/{projectId}/theme/published.json. Its ready address is shown next to the document template, in Project settings → Delivery → Address of the published theme. See Theme.
It is public#
Anyone who has the address can read the file. No token is asked for, by design: the user of your app is not signed in to our service, and they see the content of the screen anyway.
The only thing that keeps a stranger from reading it is that the address cannot be guessed: the organization and project IDs in it are random. Treat the address accordingly, and do not put anything into a screen that must not be public.
What it is good for#
- Prototypes and demos — a working app without any backend work.
- Small mobile apps that are fine reading screens from a third-party storage and always showing the latest publication.
- Server-side reads — your server fetches the file and hands it to your pages.
It is not a fit when:
- your users' devices must not read from infrastructure outside your control;
- you need to serve different document versions to different renderer versions;
- the app is a web page in a browser — see Reading from a browser.
Use the webhook for these.
Reading from a browser#
A web page on your own domain cannot fetch the direct address. The storage answers cross-origin requests only for the origins it was told to allow, and your site is not one of them. Mobile apps and servers are not browsers: they are not affected.
This is what it looks like, and why it is easy to misread:
$ curl -s -o /dev/null -w "%{http_code}" "https://firebasestorage.googleapis.com/v0/b/ui-builder-dd4af.firebasestorage.app/o/orgs%2F<orgId>%2F…%2Fpublished.json?alt=media"
200
// the same address, from a page on https://app.example.com
fetch(url) → TypeError: Failed to fetch
(console: blocked by CORS policy: no 'Access-Control-Allow-Origin' header)The file is there and public; the browser refuses to hand the answer to a page of another origin.
| Your web app gets the document | How |
|---|---|
| from your own backend — recommended | Turn the webhook on, store what arrives, and serve it from an address of yours (/api/ui/documents/{documentId}). The browser talks only to you, and you choose the caching and the CORS. |
| on your server | Fetch the direct address in server code — a Next.js Server Component, a route handler, your API — and pass the JSON to the page. A server is not subject to CORS. |
| from the browser, straight from our storage | Possible only after your origin has been added to the storage's CORS settings. Write to contact@hotlayout.com with the origins (https://app.example.com). |
The same applies to the published theme. The React side of each option is in the React quickstart.
A Flutter web build is a browser too: the rule above applies to it, while the same app built for iOS or Android reads the address directly.
Caching#
Caching of the direct address is not part of the contract: do not rely on ETag or Cache-Control to learn about a new publication. If you need to know the moment a screen changes, use the webhook.
Related pages#
- Backend guide — what to store, endpoints, versions, cache.
- Data dictionary — the
dataContractkey in detail. - Flutter, React, iOS and Android quickstarts — reading a document in the app.
- Compatibility — which document versions a renderer understands.
- Embedding the builder — the
document-publishevent of the editor, for your back-office page. - Overview