Vai al contenuto

Widget — Technical integration

Developer guide: script, init() payload, company association and creation, lifecycle, authenticated restore (restore_id), CSP, and verification.

Path pages: Overview · B2C · B2B.

Reference edition: 26 October 2026. Restore across devices is additional to the company payload. Before enabling it, check that provisionRestoreId is available in your installation.

The interface runs in an iframe. The host site does not need Angular, a Supabase client, or access to the Quoting24 database.


Choose the integration type

Case Implementation Continuity
Public site, unknown visitor Script only. Contact form Guest session in the same browser
Site with customer data Script and init() with profile and company Prefilled guest session and company association
Portal with personal history across devices Also a backend for external_user_id and restore_id Personal history on the authorized widget channel

Associating a company and restoring history are separate operations. external_id, company_name, name, email, and phone do not authorize another person’s conversations. A user authenticated on the site remains a widget guest until you provide a valid restore credential.


1. Configuration from Quoting24

In Settings → Channels (Impostazioni → Canali), open the widget channel and collect:

  • full script URL and public key
  • authorized domains, including staging and local development
  • logo, color, position, greeting, welcome, and hours

Use the URL and key of the same environment. In the examples, replace WIDGET_HOST and PUBLIC_WIDGET_KEY. The key in the URL is public and selects the channel: it is not a personal credential and not a backend integration secret.

Domains are host names (shop.example.com, localhost), without https://, port, path, or a *. prefix. example.com also authorizes its subdomains. An empty list applies no restriction. For local testing, serve the page over HTTP, not as a file.

Value Where to store it Purpose
Public key Script URL Selects the widget channel
Private integration secret Backend secrets only Authorizes create/reuse of restore_id
restore_id Private per-user store; in the page only for an authenticated session Proves which history to restore
Guest session credential Site localStorage, per widget key, managed by the loader Guest continuity in the same browser

Restore needs a backend integration, a private endpoint, and a secret, separate from the public code. See section 6.


2. Installation

Floating button

Once, before </body>:

<script
  src="https://WIDGET_HOST/assets/widget/script.js?key=PUBLIC_WIDGET_KEY"
  async
></script>

The button appears after the visual settings load. A new visitor opens the form with phone and optional name. The loader stores the guest credential in localStorage, separated by key. If storage is blocked, continuity may be limited to the current page.

The loader exposes a single window.Quoting24Widget. A second script does not create a second widget.

Embedded chat

Container before the script, explicit height:

<section aria-labelledby="support-heading">
  <h2 id="support-heading">Support</h2>
  <div id="support-chat" style="width: 100%; height: 600px;"></div>
</section>

<script
  src="https://WIDGET_HOST/assets/widget/script.js?key=PUBLIC_WIDGET_KEY&amp;mode=embedded&amp;target=support-chat"
  async
></script>

target is the ID, without #. The iframe fills the container. There is no floating button. open(), close(), and toggle() have no effect.

Wait for the loader

For prefilled data, use this module instead of the static tag, imported once in the main layout. The examples are JavaScript modules: a bundler, or <script type="module" src="/assets/support.js"></script>.

// /assets/quoting24-loader.js
const widgetScriptUrl =
  "https://WIDGET_HOST/assets/widget/script.js?key=PUBLIC_WIDGET_KEY";

let widgetPromise;

export function loadWidget() {
  if (window.Quoting24Widget) {
    return Promise.resolve(window.Quoting24Widget);
  }
  if (widgetPromise) return widgetPromise;

  widgetPromise = new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.id = "quoting24-widget-script";
    script.src = widgetScriptUrl;
    script.async = true;
    script.onload = () => {
      if (window.Quoting24Widget) resolve(window.Quoting24Widget);
      else {
        script.remove();
        reject(new Error("Widget API is not available"));
      }
    };
    script.onerror = () => {
      script.remove();
      reject(new Error("Widget script failed to load"));
    };
    document.body.appendChild(script);
  }).catch((error) => {
    widgetPromise = undefined;
    throw error;
  });

  return widgetPromise;
}
import { loadWidget } from "/assets/quoting24-loader.js";

const widget = await loadWidget();
widget.init({
  external_id: "52",
  company_name: "Ricambi Stella S.r.l.",
  name: "mario.rossi",
  phone: "+393331234567",
  email: "amministrazione@ricambi-stella.example",
});

For embedded mode, add &mode=embedded&target=support-chat to the URL and mount the container first. Do not duplicate this load with a static script tag.

loadWidget() reports that the API is available. init() returns void: it queues the profile if the iframe is not ready and does not wait for the session. For the outcome, use isReady() and hasSessionError().


3. init() fields

All values are strings. Omit unknown ones. Do not send null, numeric IDs, or an empty email. If a field exceeds the limits or the email is malformed, the request is rejected.

Field Limit and behavior
external_id Maximum 100 characters. Company reference compared exactly with web_code, in the widget organization. Spaces are not removed
company_name Maximum 250 characters after leading and trailing spaces are removed. Creates a missing company only together with external_id. An empty string or a string of only spaces does not enable creation
name Maximum 250 characters. Contact name; it can update it in the current session
email Valid address. On public initialization it is saved only on a new company. It is not written on the contact or on a company that already exists
phone Maximum 80 characters. Contact number; prefer international format, for example +393331234567. A conflict with an existing number does not merge contacts or chats
external_user_id Optional in the base case. Stable person ID, non-empty, maximum 100 characters after trim. By itself it does not restore history
restore_id Private credential, maximum 200 characters, obtained from the backend. Requires the matching external_user_id. Use the returned value without modifying it

Example when the site knows the account. Field names in the ERP can differ: map the company reference to external_id, the legal name to company_name, the person to name, and the account email to email.

widget.init({
  external_id: "52",
  company_name: "Ricambi Stella S.r.l.",
  name: "mario.rossi",
  phone: "+393331234567",
  email: "amministrazione@ricambi-stella.example",
});

The form labels Name and Telephone are sent as name and phone. The email is email. Do not send Name, Telephone, or Email.

widget.init({
  name: "Mario Rossi",
  phone: "+393331234567",
});
widget.init({
  email: "mario.rossi@ricambi-stella.example",
});

The first call opens the chat with name and phone and does not look up a company. The second is enough with a valid email. In the full profile above, phone is the contact number and email is written on the company only if this call creates the company.

Keep name and company_name separate. Do not concatenate the legal name onto the user name to obtain the association. Do not send a web_code field: the public name is external_id. Do not use the Quoting24 internal UUID or companies.code in place of the value in web_code.

init(profile) replaces the profile stored by the loader. It does not merge: pass the fields you want to keep.

A new guest can start with at least a name, a phone, a valid email, or an external_user_id, or with a company found or created through external_id. init({}) and company_name alone do not start a new session. If you send only an unknown external_id, without company_name, the widget asks for the data through the form.

init({ restoreId: "..." }) does not transmit the credential. The field is restore_id.


4. Associate or create the company

The flow runs when Quoting24 processes initialization, even without a message:

  1. If it receives a non-empty external_id, it looks for a company in the same organization whose web_code equals that value
  2. If it finds one, it links the contact. The existing company’s name and email stay unchanged
  3. If it does not find one and it also receives a non-empty company_name, it creates the company with name = company_name, web_code = external_id, and the email when present, then links the contact
  4. If it cannot find or create the company because data is missing, it continues with the available profile. If that is not enough for a new guest, it asks for the form. Database errors or the request limit fail initialization
Data sent Situation in Quoting24 Result
external_id: "52", company_name: "Ricambi Stella S.r.l." web_code = "52" exists Uses the existing company; does not change its name
Same data, plus a valid email web_code = "52" does not exist Creates the company with web code 52, legal name, and email, and associates the contact
external_id: "52", name: "mario.rossi" No match Starts the guest without a new company association
external_id: "52", with no other data A match exists Starts the associated guest, with the default name Visitatore
external_id: "52", with no other data No match Asks for the visitor’s data
company_name without external_id, plus name Any Starts the visitor. Does not search for or create a company

The search does not use legal name, email, phone, VAT number, or companies.code. "052" and "52" are different values. If you import the registry first, fill web_code with the same value the site will send in external_id.

The association applies to the contact. For a contact that is already linked, a new valid code can change their company. Omitting the code, or sending one that does not resolve, does not clear the previous association. On a person or account change, run reset(): do not only change the profile.

Limits

web_code has no uniqueness constraint. With several matches, the first one is selected, ordered by ascending creation date. Avoid duplicates: simultaneous requests that do not find the company yet can create more than one. The flow does not guarantee atomic creation and does not replace an import or a sync from the ERP.

Implicit creation is limited to 20 attempts per 60-second window and IP address, in the backend process that handles the request. Above the limit, initialization fails with TOO_MANY_REQUESTS. Retry with a delay. Avoid continuous init() loops.

The data comes from the browser and the visitor can modify it. The association does not prove membership or identity and does not grant company history. Send company_name if you want to allow this public creation. To update existing companies, use Quoting24 or an authorized ERP integration.


5. Login, logout, and account change

Keep a single instance in the main structure of a SPA. A page change does not need a new script or a reset.

On a person or account change, and on logout, call reset() before the new profile. On reload with the same login still active, use init() without reset, as at startup: that keeps the saved guest session. The site must distinguish a reload from an actual change.

Call this on authentication changes. profile is the new user’s profile, or null on logout.

import { loadWidget } from "/assets/quoting24-loader.js";

let identityRevision = 0;

export async function applyWidgetIdentity(profile) {
  const revision = ++identityRevision;
  const snapshot = profile ? { ...profile } : null;

  window.Quoting24Widget?.reset();
  if (!snapshot) return;

  const widget = await loadWidget();
  if (revision !== identityRevision) return;
  widget.reset();
  widget.init(snapshot);
}

Handle the load error in the site UI, with a retry. If you fetch the profile from the backend before this function, invalidate those requests on user change as well. While waiting, clear the previous profile immediately with applyWidgetIdentity(null).

Reset forgets this instance’s guest session, the saved credential, the profile, and the unread counter. It is not a server revocation and it does not end sessions on other devices. The authentication change must also be applied in the other open tabs.

With history restore

When the profile includes restore_id, download it from the site’s authenticated endpoint after reset(), and discard the response if the user has changed in the meantime. A reload with the same user reuses the credential already saved and must not call reset().

import { loadWidget } from "/assets/quoting24-loader.js";

let authRevision = 0;
let pendingRecovery;

// Only on login, logout, and user change.
// Do not call it on reload with the same user: it calls reset() and drops the session already in progress.
export async function applyWidgetAuth(user) {
  const revision = ++authRevision;
  const expectedUserId = user ? String(user.id) : null;
  pendingRecovery?.abort();
  const controller = new AbortController();
  pendingRecovery = controller;

  window.Quoting24Widget?.reset();

  try {
    const widget = await loadWidget();
    if (revision !== authRevision) return;
    widget.reset();
    if (expectedUserId === null) return;

    const response = await fetch("/api/widget/session", {
      method: "POST",
      credentials: "same-origin",
      cache: "no-store",
      signal: controller.signal,
      headers: { "Content-Type": "application/json" },
      body: "{}",
    });
    if (!response.ok) throw new Error("Widget restore is unavailable");
    const profile = await response.json();
    if (revision !== authRevision) return;
    if (profile.external_user_id !== expectedUserId || !profile.restore_id) {
      throw new Error("Unexpected restore response");
    }

    const payload = {
      external_user_id: profile.external_user_id,
      restore_id: profile.restore_id,
    };
    for (const key of ["external_id", "company_name", "name", "email", "phone"]) {
      if (profile[key]) payload[key] = profile[key];
    }
    widget.init(payload);
  } catch (error) {
    if (controller.signal.aborted || revision !== authRevision) return;
    console.warn("Support chat: restore failed.", error);
  }
}

The loop copies only fields that are present and non-empty. An email "" or a numeric ID must not be sent.

JavaScript API

Method Use
init(profile) Provides or replaces the current profile. The loader does not merge fields into a previous profile
open() / close() / toggle() Opens, closes, or toggles the floating widget. Closing is not a logout. In embedded mode they have no effect
isReady() Visitor session initialization succeeded. Stays false during the contact form
hasSessionError() Session error reported by the iframe
reset() Clears profile, local session, saved guest credential, and unread counter
dispose() Unmounts the interface and listeners. Does not clear the saved guest credential or the global API object

You can connect a site button to window.Quoting24Widget?.open() when the floating button has appeared. Do not wait for isReady() to let a new guest open the form. There is no public availability event. These indicators do not describe the whole network state.

A resolved loadWidget() does not mean that branding and session are ready.

Full unmount

If you must remove the widget, run dispose(). If unmount coincides with logout, run reset() first. Before loading the script again, delete the global and reset the loader Promise.

export function removeWidget() {
  window.Quoting24Widget?.reset();
  window.Quoting24Widget?.dispose();
  delete window.Quoting24Widget;
  document.getElementById("quoting24-widget-script")?.remove();
  widgetPromise = undefined;
}

widgetPromise is the one defined in the loader module. Run the unmount after the script load has finished. Do not run it concurrently with a new loadWidget(). For authentication changes use applyWidgetIdentity() or applyWidgetAuth(), which wait for the loader.

The local reset does not revoke restore_id on the server.


6. Authenticated restore

The guest session reads its most recent conversation even after it is closed. The conversations visible for the same person on another browser require external_user_id and restore_id together.

widget.init({
  external_id: "52",
  company_name: "Ricambi Stella S.r.l.",
  name: "Mario Rossi",
  phone: "+393331234567",
  email: "mario.rossi@ricambi-stella.example",
  external_user_id: "1042",
  restore_id: session.restore_id,
});

Restore does not merge old guest sessions and does not share history among colleagues of the same company.

Endpoint on the site backend

Example to implement in the portal, not a Quoting24 URL.

POST /api/widget/session
Content-Type: application/json

{}
{
  "external_user_id": "1042",
  "external_id": "52",
  "company_name": "Ricambi Stella S.r.l.",
  "name": "Mario Rossi",
  "phone": "+393331234567",
  "email": "mario.rossi@ricambi-stella.example",
  "restore_id": "<credential-returned-by-quoting24>"
}

Set Cache-Control: no-store. Identity and company come only from the server session. Apply the application’s auth and CSRF. Do not log bodies that contain the credential.

If the portal already exposes an authenticated endpoint that returns the current user’s restore_id for this widget, use that response and combine it with the person ID and the company reference.

Server-to-server provisioning

Typical path: /fastify/trpc/widget.provisionRestoreId
(or /trpc/widget.provisionRestoreId if the backend is exposed directly).

POST /fastify/trpc/widget.provisionRestoreId
Authorization: Bearer <INTEGRATION_SECRET>
Content-Type: application/json

{
  "widget_api_key": "<public-widget-uuid>",
  "external_user_id": "1042",
  "external_id": "52",
  "name": "Mario Rossi",
  "phone": "+393331234567",
  "email": "mario.rossi@ricambi-stella.example",
  "attempt_id": "1042"
}
{
  "result": {
    "data": {
      "restore_id": "<opaque-credential>"
    }
  }
}

Generated values start with q24-restore-v1:. Do not invent them and do not derive them from public IDs.

Input Requirement
widget_api_key Mandatory UUID, authorized for the secret
external_user_id Stable person ID, equal to the one in init()
attempt_id Strictly increasing positive integer, as a decimal string, maximum 9223372036854775807
saved_restore_id Credential already saved. Omit it on the first provisioning
external_id, name, email, phone Optional, with the same limits as init()

Ensure pattern, retrieve or create:

  1. Authenticate the user and take a lock on the restore row, in a transaction
  2. Allocate a new attempt_id from a sequence after the lock (CACHE 1, not cyclic; the sequence survives rollback)
  3. Call Quoting24 with attempt_id and, if present, saved_restore_id
  4. Save result.data.restore_id and commit
  5. Only after the commit, return the credential to the browser

A valid saved_restore_id must be reused. Omitting it when a valid key already exists replaces the key and invalidates restored sessions. Do not generate a new key on every page view.

If remote provisioning succeeds and the local save fails, return an error. Do not return the unsaved key. Use limited timeouts.

company_name is sent in init(). The provisioning body above does not include it: company association and creation, described in section 4, happen when the browser initializes the session. The email of public initialization is written only on a new company.

Outcome on the widget

Quoting24 checks the credential and loads the widget history visible for that user. Closed chats can appear as a previous conversation: reading them does not reopen the old conversation; sending stays on the current conversation.

A credential that is invalid, belongs to another user, or has been revoked is rejected, with no automatic conversion to a guest session. The widget’s «Riprova» button does not by itself request a new restore_id. The current interface is in Italian.


7. Page, network, and appearance

  • One instance per page
  • CSP. Allow the widget origin in script-src and frame-src, and the API origin indicated for the installation in connect-src (loading visual settings). Allow configured images in img-src. The loader inserts inline styles and does not expose a public nonce option: check the site CSP. The page CSS does not modify the inside of the cross-origin iframe
  • Iframe connections. It must reach the API, Realtime/WebSocket, and storage of its own installation. For network filters, use the environment’s actual hosts
  • Appearance and language. Logo, color, position, and messages are configured in Quoting24. company_name is the customer’s legal name. There is no public language or locale parameter; the current interface is in Italian
  • Mobile. The floating chat can fill the viewport. In embedded mode the site controls height and width
  • Attachments and hours. PDF, DOC/DOCX, JPG/JPEG, and PNG. Up to 10 MiB per file and 100 MiB of referenced attachments per conversation. Outside working hours the widget shows the banner and leaves composition available

8. Verify the integration

Use a test account and a test channel. Check the registry in Quoting24, as well as the chat.

Test Expected result
New browser, script only Contact form, then conversation
Full payload with external_id already present as web_code Contact associated with the existing company; company name and email unchanged
Full payload with a new code Company created with web code, legal name, and email; contact associated
New code and name, without company_name Chat started, no company created
New code only Contact form
Legal name and name only No company searched or created by name
Empty email, numeric ID, or legal name too long Payload rejected
Second init() in the same session Continues the guest session. Email and phone do not recover another person’s chat
Reload, SPA navigation, mobile One instance; guest continuity if storage is available; usable layout
Logout or account change during loading Previous session cleared; late results do not restore the old profile
Same authenticated user, another browser, valid restore_id That person’s history
Two users of the same company Distinct credentials and histories
Guest, then restore reset() in between; sessions are not merged
Invalid or revoked credential Error, with no automatic guest fallback
Previous conversations Read only; sending stays on the current one
Unauthorized domain Access rejected
Network interrupted Reconnection; draft kept if sending fails

Troubleshooting

Symptom Check
The button does not appear URL, key, channel enabled, domains, CSP, widget.getBranding request. The JavaScript global alone does not prove that the button exists. open() has no effect until branding has loaded, and it has no effect in embedded mode
Quoting24Widget is undefined Wait for the load. Check the key parameter
The embedded chat is invisible Container before the loader, correct ID, height greater than zero
Domain not allowed Host name of the site, without scheme, port, and path. Also check staging. The page must be served over HTTP
Phone form despite the profile Async order, snake_case, empty values, a previous reset(), profile insufficient for a new guest
The company is not found Compare external_id with web_code in the same organization, including spaces and leading zeros
The missing company is not created Both external_id and company_name must be non-empty. Check validation and the creation limit
Company name or email does not change Expected: init() creates missing companies and does not update existing ones
The email does not appear on the contact Expected for public initialization: the email is used only when the company is created
TOO_MANY_REQUESTS Wait and retry. Avoid repeated calls or bulk imports through the widget
History is missing on another device The company payload does not restore it. external_user_id and restore_id are required
Restored sessions invalidated Ensure calls must send saved_restore_id again. Do not issue a new key on every page view
«Riprova» is not enough A new restore_id from your endpoint, then reset() and init()
The old chat remains after an account change reset() immediately on identity change. Discard previous async responses
After dispose() the script does not recreate the widget Delete the global and the script, reset the loader Promise, then load again
The interface stays in Italian No public language or locale option on the loader

Implementation references

  • Loader and JavaScript API: @frontend/src/public/widget/script.ts
  • Initialization, company association, and implicit creation: @backend/fastify/routes/widget/initialize.ts
  • Initialization and company tests: @backend/fastify/__tests__/widget/widgetInitialize.test.ts
  • Domain and channel: @backend/fastify/routes/widget/procedures/publicWidgetProcedure.ts
  • restore_id provisioning: @backend/fastify/routes/widget/provisionRestoreId.ts