Sonela

Locking the public assistant to your own website

Four settings and one small piece of server code, and the assistant answers on the pages your own server just rendered — and nowhere else.

Who this is for. The person who runs your Sonela workspace does sections 1 to 4 and 8 to 11 in the dashboard. Sections 5 to 7 are for whoever maintains your website — hand them this page. Nothing here needs a Sonela account except the dashboard steps, and nothing here is secret except the one value section 4 tells you to keep that way.

1. What you get, and what you do not

The public assistant's key is public by design: it sits in your page's source, and a copy of it works from anywhere. Everything that constrains it is on our side — which websites it may appear on, how many questions a day, and the fact that a public session never reaches your database, cannot read a visitor's screen and cannot take a picture of it. This guide adds the one control that is yours: a signature only your own server can produce, put on the page as it is rendered, and checked before the assistant opens a session.

Be exact about what that buys, because it is easy to oversell:

What it answers from is what you opened to visitors: the part of your business context written for them, the manuals you made public, your website, your social posts and, for a shop, your product catalog. A product it recommends is drawn under the answer as a card with its picture, price and a link to its page; the product catalog guide says how to give it yours.

2. Before you start

3. The settings that need no code

Open Widget → Public assistant. Under Settings:

  1. Websites it may appear on. The exact origins that serve the page, comma-separated: https://www.example.com, https://example.com. An origin is scheme and host, with a port only if it is not the default — no path, no trailing slash, no wildcard. The dashboard refuses anything else as you type. If visitors reach the site both with and without www, list both, or the one you leave out silently gets no assistant.
  2. Questions a day. Set it to what your busiest day genuinely needs, between 1 and 5,000. Past it, visitors are told the assistant is unavailable until tomorrow; nothing else changes. It is the ceiling on what anyone at all can spend of your monthly quota through this key.
  3. Page loads that show it. 100% puts the bubble on every page load. Lower it to show the assistant to fewer visitors on a busy site: each full page load draws on its own, so a visitor who does not get the bubble may get it on a later one. On a site that changes pages without reloading, one load is the whole visit. It is your website alone; the link and the app always open.
  4. Name. What the assistant calls itself to visitors. Nothing to do with security, but it is on the same screen.
  5. Press Save settings.

Leave Only answer pages my own server signed off for now. It refuses to save until a signing secret exists, and it should stay off until your pages sign (section 8).

4. Generate the signing secret

Open Widget → Security. Under Signing secret, press Generate signing secret. It is shown once, in an amber sheet, and never again: Sonela keeps a protected copy it can verify against but not read back. Copy it now.

It begins sk_ and is one line. The whole line, prefix included, is the key your server signs with in the next section — do not strip the prefix.

The secret goes into your server's environment and nowhere else. Name it SONELA_SIGNING_SECRET so the samples below work unchanged. If it ever appears in page source, a repository, a ticket or a chat, rotate it (section 11).

Generating changes nothing yet. The same secret also powers Require vouched sessions and Require vouchers to name the organisation on that screen, which belong to a different feature for assistants inside an app your visitors sign in to and never touch a page signature; leave those switches alone.

5. Sign every page your server renders

On every page that carries the assistant, at the moment your server renders it, compute two values and put them on the script tag (section 6). The rules are exact, because a signer that hashes something slightly different does not fail loudly — the assistant simply never opens.

Value Rule
timestamp The current time in whole Unix seconds, as a string of digits and nothing else — no sign, no decimals, no milliseconds. Sonela accepts it up to 30 seconds ahead of its own clock and up to 8 hours behind, so keep your server's clock right.
what is signed The text page: followed by the timestamp — for example page:1788091200. Nothing before, nothing after, no spaces.
how HMAC-SHA256, with the secret as the key: the whole sk_… line, as UTF-8 bytes.
signature The 32-byte result as 64 hexadecimal characters. Upper or lower case both verify; the samples produce lower case.
when Per render. Never a value computed once and reused, never one written into a static file — it would expire eight hours later and every page would go dark.

Check your signer against this vector first

With the secret sk_ followed by 64 letter as and the timestamp 1788091200, the signature must be exactly:

e5d7708b944c600435647b60538f9027cee1516ef6f5ab56fdd964853b09bdac

Run your code once with those two inputs. If it prints anything else, the preimage, the key or the encoding is wrong, and switching the setting on would stop your assistant. Once it matches, swap in the real secret and the real time.

Node.js

const crypto = require('crypto');

const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
  .createHmac('sha256', process.env.SONELA_SIGNING_SECRET)
  .update('page:' + timestamp)
  .digest('hex');

PHP

$timestamp = (string) time();
$signature = hash_hmac('sha256', 'page:' . $timestamp, getenv('SONELA_SIGNING_SECRET'));

Python

import hashlib, hmac, os, time

timestamp = str(int(time.time()))
signature = hmac.new(
    os.environ["SONELA_SIGNING_SECRET"].encode("utf-8"),
    f"page:{timestamp}".encode("utf-8"),
    hashlib.sha256,
).hexdigest()

C# (.NET 6 or later)

using System.Security.Cryptography;
using System.Text;

var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signature = Convert.ToHexString(HMACSHA256.HashData(
        Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("SONELA_SIGNING_SECRET")!),
        Encoding.UTF8.GetBytes("page:" + timestamp)))
    .ToLowerInvariant();

Java (17 or later)

import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

String timestamp = Long.toString(Instant.now().getEpochSecond());
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
    System.getenv("SONELA_SIGNING_SECRET").getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = HexFormat.of().formatHex(
    mac.doFinal(("page:" + timestamp).getBytes(StandardCharsets.UTF_8)));

6. Put the two values on the script tag

Take the tag from Widget → Public assistant — "Paste into your website" — and add exactly two attributes. Everything else stays as the dashboard printed it:

<script src="https://api.sonela.ai/sonela.js"
        data-key="pk_…"
        data-page-timestamp="1788091200"
        data-page-signature="e5d7708b944c600435647b60538f9027cee1516ef6f5ab56fdd964853b09bdac"
        defer></script>

Your template fills data-page-timestamp and data-page-signature from the two values your server just computed. Do not add any other attribute: the assistant's name, colour, greeting and corner are settings in the dashboard, and a tag that configures them from the page is configuring the assistant from the one place a visitor can edit.

One exception, for a page people keep open: a signature is good for eight hours, and a tab left open longer than that cannot start a new conversation on the signature it was rendered with. If your pages stay open for hours — a web application rather than a brochure — also serve the two values as JSON from a path on your own domain, in the shape section 7's function answers, and name that path in data-vouch-url="/api/sign-page" on the tag. The assistant then asks it for a fresh signature before each session it starts, and falls back to the two attributes when the path does not answer in time.

7. A site of static files

A site with no server has nothing to sign with — unless its host runs a small function beside the files, as Azure Static Web Apps, Netlify, Cloudflare Pages and Vercel all do. Then the shape changes in one way: the page cannot carry the values, so a tiny loader asks the function for them and adds the tag itself. Sonela's own website works exactly like this, and both halves are short.

The signing function

One HTTP endpoint on your site's own domain, say /api/sign-page, answering GET with JSON and a Cache-Control: no-store header. The secret is the host's application setting SONELA_SIGNING_SECRET. Shown for Azure Static Web Apps (Node 22, the @azure/functions v4 package); every other host's function is the same twenty lines in its own wrapper.

const crypto = require("crypto");
const { app } = require("@azure/functions");

app.http("sign-page", {
  methods: ["GET"],
  authLevel: "anonymous",
  route: "sign-page",
  handler: async () => {
    const secret = process.env.SONELA_SIGNING_SECRET;
    const headers = { "Cache-Control": "no-store" };
    if (!secret) {
      return { status: 503, headers, jsonBody: { error: "no signing secret on this host" } };
    }
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const signature = crypto
      .createHmac("sha256", secret.trim())
      .update("page:" + timestamp)
      .digest("hex");
    return { status: 200, headers, jsonBody: { timestamp, signature } };
  },
});

The loader

Replace the assistant's tag on every page with <script src="/assets/assistant.js" defer></script>, and put this in that file, with your own key on the first line:

(function () {
  "use strict";
  var KEY = "pk_…";
  var RUNTIME = "https://api.sonela.ai/sonela.js";
  var SIGNER = "/api/sign-page";
  var TIMEOUT_MS = 8000;

  function isSignature(v) {
    return !!v && typeof v.timestamp === "string" && /^[0-9]{1,12}$/.test(v.timestamp)
      && typeof v.signature === "string" && /^[0-9a-f]{64}$/.test(v.signature);
  }

  function mount(signed) {
    var tag = document.createElement("script");
    tag.src = RUNTIME;
    tag.dataset.key = KEY;
    if (signed) {
      tag.dataset.pageTimestamp = signed.timestamp;
      tag.dataset.pageSignature = signed.signature;
    }
    tag.dataset.vouchUrl = SIGNER;
    document.head.appendChild(tag);
  }

  if (typeof window.fetch !== "function" || typeof window.AbortController !== "function") {
    mount(null);
    return;
  }

  var settled = false;
  var deadline = new window.AbortController();
  var timer = window.setTimeout(function () { deadline.abort(); }, TIMEOUT_MS);

  function settle(signed) {
    if (settled) return;
    settled = true;
    window.clearTimeout(timer);
    mount(signed);
  }

  window
    .fetch(SIGNER, { cache: "no-store", signal: deadline.signal })
    .then(function (r) { return r.ok ? r.json() : null; })
    .then(function (s) { settle(isSignature(s) ? s : null); }, function () { settle(null); });
})();

Three things about it are deliberate. It mounts the assistant unsigned when it cannot get a signature — the function is missing, has no secret yet, or is slow — so deploying the loader before section 9 changes nothing; whether an unsigned page may answer is the dashboard's decision, not the loader's. It fetches with no-store, because a signature is a fact about now. And it names the function in data-vouch-url, so the assistant asks it for a fresh signature before each session it starts: a signature is good for eight hours, and a page a visitor leaves open longer than that would otherwise have to be reloaded before it could answer again.

If your site's pages carry a Content-Security-Policy, connect-src must allow 'self' for the loader to reach the function, alongside the https://api.sonela.ai that the assistant already needs in script-src and connect-src.

8. Check it before you switch it on

Sonela checks the signature only once the setting is on, so these checks are yours, and they take five minutes:

  1. The vector matched (section 5). If you skipped it, do it now — it is the one check that catches a wrong preimage before a visitor does.
  2. The page carries both values. Open a page that has the assistant, view its source (or, with the loader of section 7, the browser's developer tools → Elements, where the mounted tag appears in <head>). The tag has data-page-timestamp with ten digits and data-page-signature with 64 hexadecimal characters.
  3. They are fresh. Reload twice a few seconds apart: the timestamp moves, and the signature with it. If either stays the same, a cache is serving an old render — the page must be rendered, or the function called, per request.
  4. The timestamp is now. Compare it with the current Unix time (date +%s on a Mac or Linux shell, [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() in PowerShell). More than 30 seconds ahead, or hours behind, and the server's clock is wrong.
  5. Every page. The assistant is per page, so a page you forgot to sign is a page where it stops. Check one of each template.

9. Switch on signed pages only

  1. Widget → Public assistant, under Settings, tick Only answer pages my own server signed. A box appears with what your server must output; you did that in section 5.
  2. Press Save settings.
  3. Open your website in a private window, open the assistant and ask it something. It answers. That is the whole confirmation.

What changes the moment it is saved:

If it refuses to save with "Generate a signing secret first.", section 4 was skipped. Switching it off is always allowed, immediately, from the same box — so if a page turns out not to sign, untick, save, fix the page, and come back.

10. When it does not work

What you see What it means What to do
"The assistant could not verify this page" on your own site, on every page The signature does not verify: wrong preimage, secret pasted with the prefix stripped, a timestamp in milliseconds, or the secret on the server is not the one the dashboard holds. Untick the setting and save, so visitors are served. Run the vector (section 5). Check the environment variable is the whole sk_… line. Turn it back on.
The same, on one page or one template only That page renders the tag without the two attributes, or from a cached copy. Sign that template; make sure it is rendered per request.
Fine at first, then failing hours later A CDN or page cache is serving a render older than eight hours, so its signature has expired. Or the page itself has been open that long: the assistant starts a new session every half hour of use, and each one needs a signature younger than eight hours. Cache the page for less than eight hours, or exclude it from the cache. With the loader of section 7 the page itself may be cached; only the function's answer must not be. For a page people keep open, name the signing function in data-vouch-url (sections 6 and 7) so each session fetches a signature of its own.
Failing on some visitors' machines, not others Nothing to do with their machines: the check is on your server's clock. Compare the server's time with a reference. More than 30 seconds fast, or hours slow, is refused.
The public link or the app stopped opening By design while signed pages only is on (section 1). Untick the setting if you need the link.
Rotated the secret, and pages began failing a day later Rotation keeps the old secret working for 24 hours; the server was still signing with it when the grace ended. Put the new secret in the server's environment and restart what needs it.
The bubble is on some page loads and not on others Page loads that show it is below 100%, and each load draws on its own. Set it to 100% if every visitor should get the bubble.
The dashboard refuses to save the setting "Generate a signing secret first": there is no secret to verify against. Section 4.

Your web developer can also read the browser console on a failing page: the assistant prints one line there beginning Sonela: the server refused this page's visitor voucher, and nothing else about it.

11. Rotating the secret

On Widget → Security, Rotate signing secret issues a new one and keeps the old one working for 24 hours, so you can update the server at your own pace: pages signed with either verify until the grace ends, and the screen shows the deadline while it is open. Update SONELA_SIGNING_SECRET before then.

If you are rotating because the old secret leaked, press Revoke previous secret right after updating the server. That ends the overlap at once; anything still signing with the old value is refused from that moment.

12. What to watch afterwards

13. The checklist


Something here wrong or missing? Write to hello@sonela.ai.