Skip to content
HotLayout Docs

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#

  1. Open Project settings → Delivery → Webhook. Only an owner or an admin of the organization can set up a webhook.
  2. Enter the Webhook address — an https address on your backend that accepts a POST request — and press Turn on webhook.
  3. 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.

  1. Take t and v1 from the x-webhook-signature header.
  2. Compute HMAC-SHA256 of <t>.<raw body> with your secret as the key.
  3. Compare the result with v1 in constant time.
  4. Reject the request if t is 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 2xx is lost on the way, the same publication arrives again with the same x-webhook-delivery. Treat it as already done: the key documentId + stamp is the same.
  • Each attempt is signed anew. t, deliveredAt and therefore v1 differ between attempts; x-webhook-delivery does 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.json

and 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=media

The 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=media

What you get:

  • The latest publication only. The file is replaced on every publication. There is no schemaVersion parameter 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.