Lectico

Qué es

<lectico-form> es el web component que renderiza un formulario (o ticket) de Lectico en cualquier página web. Lees su esquema desde el servidor, se pinta dentro de un shadow DOM y envía las respuestas a Lectico, que crea un ticket en tu bandeja de soporte.

Esta página cubre todas las formas de integrarlo: desde un <script> de una línea hasta una integración headless con identidad firmada para apps con usuario logueado. Está dirigida a desarrolladores. Si solo quieres crear y configurar formularios desde el panel, empieza por Formularios.

IDs que necesitas (panel → Formularios):

  • form-id (público): frm_xxxxxxxxxxxx
  • api-key (pública del widget): pk_live_…
  • Opcional, Pro+ — el secreto de identidad del workspace (panel → Formularios → tu formulario → Instalar → pestaña «Identidad firmada»).

Instalación

Carga el script del web component (módulo ES) una sola vez y coloca el elemento <lectico-form> donde quieras que aparezca el formulario.

<script type="module"
  src="https://api.lectico.com/storage/v1/object/public/widget/v1/lectico-form-element.esm.js"></script>

<lectico-form api-key="pk_live_xxx" form-id="frm_xxx" locale="es"></lectico-form>

El formulario es fluido (ocupa el 100% de su contenedor por defecto), se renderiza en su propio shadow DOM y carga su esquema desde api.lectico.com.

Dos backends

El widget habla con dos servicios distintos. No necesitas configurarlos: los usa automáticamente. Conviene conocerlos si integras headless o depuras una request.

OperaciónEndpointHostHeader de auth
Cargar esquema (config)GET /v1/forms/:id/configapi.lectico.comLectico-Api-Key: pk_live_…
Enviar respuesta (submit)POST /api/forms/:id/submitforms.lectico.comLectico-Api-Key: pk_live_…

La separación es intencional: la config se sirve cacheada desde Cloud Run y el submit pasa por la capa de validación (Zod, honeypot, rate-limit, comprobación de origen) antes de crear el ticket.

Atributos

Todos los atributos van sobre el elemento <lectico-form>. Solo api-key y form-id son obligatorios. El atributo del embed sobrescribe el valor por defecto que hayas configurado para ese formulario en el panel.

AtributoReq.ValoresDescripción
api-keypk_live_…Clave pública del widget.
form-idfrm_…ID público del formulario.
localenoes / enIdioma de los textos del formulario. Por defecto se autodetecta (lang del documento → idioma del navegador → es).
prefill-namenotextoRellena el campo name.
prefill-emailnotextoRellena el campo email.
prefillnoJSONPrefill de varios campos por id, p. ej. prefill='{"plan":"pro"}'.
hidden-fieldsnoCSV de idsCampos a ocultar. Sus valores se envían igual.
identitynoJWT HS256Token de identidad firmada (Pro+). Ver Identidad firmada.
widthnofluid / nº / longitud CSSAncho del formulario. fluid (def., equivale a full / 100%) = 100% del contenedor; un número como 480 o una longitud CSS (640px, 48rem) fija el ancho.
appearancenocard / barecard (def.) pinta la tarjeta (fondo, borde, sombra, padding); bare no pinta tarjeta (el host aporta el contenedor).

Prefill y ocultar campos

Si tu app ya conoce el nombre y el email del usuario, pásalos y oculta esos campos. El ticket se atribuye a esa identidad y el usuario solo ve los campos que faltan (p. ej. Asunto + Mensaje).

<lectico-form
  api-key="pk_live_xxx"
  form-id="frm_xxx"
  locale="es"
  prefill-name="Ada Lovelace"
  prefill-email="ada@tudominio.com"
  hidden-fields="name,email">
</lectico-form>

Los campos listados en hidden-fields no se renderizan, pero su valor (de prefill-* o prefill) sí se envía con la submission.

También puedes fijar el comportamiento por defecto en el panel (marcar Nombre/Email como "provistos por el host") y sobreescribirlo por atributo en el embed. La combinación ideal: default por formulario + override por embed.

Identidad firmada (Pro+)

Para que la identidad del usuario no se pueda falsear desde el cliente (estilo Identity Verification de Intercom), tu backend firma un JWT HS256 con el secreto del workspace y lo pasas en el atributo identity. Lectico verifica la firma en el servidor; si es válida, el email y nombre del token mandan (override, nunca append). Si la firma falta o es inválida en un formulario con verificación requerida, la submission se rechaza con 401.

La verificación de identidad firmada es una funcionalidad Pro+. En planes inferiores la verificación queda forzada a none (la identidad sin firmar se acepta como hasta ahora, sin garantías). Consulta Planes.

Pasos

Paso 1 · Obtén el secreto

En el panel, ve a Formularios → tu formulario → Instalar → pestaña «Identidad firmada»Generar / Rotar (también puedes Revelar el secreto vigente). El secreto se muestra una sola vez. Cópialo y guárdalo como variable de entorno en tu backend.

Nunca expongas el secreto al cliente. Vive cifrado server-side, no aparece en /config ni se envía al navegador. Fírmalo siempre en tu backend.

Paso 2 · Activa la verificación

En el panel, ve al formulario → Identidad → elige Exigir identidad firmada (rechaza submissions sin token válido, 401) o Verificar si se provee (verifica el token cuando llega, pero no lo exige).

Paso 3 · Firma el token en tu backend

Genera un JWT HS256 con el secreto. Claims del contrato:

ClaimReq.TipoDescripción
emailstringEmail autoritativo del usuario.
namenostringNombre del usuario.
subnostringID interno del usuario en tu sistema.
iatnumberEmitido en (segundos epoch).
expnumberExpira en (segundos epoch). Recomendado ≤ 1h.

Paso 4 · Pásalo al widget

Pon el token en el atributo identity (o en el campo identity de la API headless). Combínalo con prefill-* + hidden-fields para que el usuario no reescriba sus datos.

Recetas de firma

import { createHmac } from "node:crypto";
const b64url = (b) => Buffer.from(b).toString("base64url");

export function lecticoIdentityToken(secret, { email, name, sub }) {
  const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
  const now = Math.floor(Date.now() / 1000);
  const claims = { email, ...(name ? { name } : {}), ...(sub ? { sub } : {}), iat: now, exp: now + 600 };
  const payload = b64url(JSON.stringify(claims));
  const data = `${header}.${payload}`;
  const sig = b64url(createHmac("sha256", secret).update(data).digest());
  return `${data}.${sig}`;
}
import base64, hmac, hashlib, json, time

def _b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def lectico_identity_token(secret, *, email, name=None, sub=None):
    header = _b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
    now = int(time.time())
    claims = {"email": email, "iat": now, "exp": now + 600}
    if name: claims["name"] = name
    if sub: claims["sub"] = sub
    payload = _b64url(json.dumps(claims, separators=(",", ":")).encode())
    si = f"{header}.{payload}"
    sig = _b64url(hmac.new(secret.encode(), si.encode(), hashlib.sha256).digest())
    return f"{si}.{sig}"
<?php
function lectico_identity_token(string $secret, array $opts): string {
    $b64url = fn(string $b) => rtrim(strtr(base64_encode($b), '+/', '-_'), '=');
    $header = $b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
    $now = time();
    $claims = ['email' => $opts['email'], 'iat' => $now, 'exp' => $now + 600];
    if (!empty($opts['name'])) $claims['name'] = $opts['name'];
    if (!empty($opts['sub']))  $claims['sub']  = $opts['sub'];
    $payload = $b64url(json_encode($claims));
    $data = "$header.$payload";
    $sig = $b64url(hash_hmac('sha256', $data, $secret, true));
    return "$data.$sig";
}

Comportamiento de la verificación

  • Override, no append — si el token es válido, su email/name sustituyen lo que venga del formulario. Una submission firmada no se puede atribuir a otra persona desde el cliente.
  • Required — si el formulario exige identidad y el token falta o es inválido → 401.
  • Optional — si el token llega y es inválido, se ignora (la submission pasa sin verificar). Si no llega, también pasa.
  • Verificación constant-time — la comprobación de la firma es timing-safe.

Rotación: rotar el secreto invalida los tokens firmados con el anterior. La propagación tarda hasta ~5 min (caché de config). Coordina el cambio para no rechazar tráfico legítimo.

Theming

El formulario hereda tu marca con CSS custom properties y ::part(). El CSS de tu página siempre gana: el tema configurado en el panel se emite con :where(:host) (especificidad 0), así que cualquier regla que escribas sobre lectico-form lo sobrescribe.

lectico-form {
  --lectico-accent: #C8803F;     /* botón, focus, asterisco required */
  --lectico-radius: 12px;        /* radio de la tarjeta */
  --lectico-font: "Inter", sans-serif;
  --lectico-max-width: 100%;     /* fluido (por defecto) */
}
lectico-form::part(submit) { letter-spacing: .02em; }
lectico-form::part(input)  { background: #faf7f2; }
lectico-form::part(field)  { margin-bottom: 18px; }

Custom properties

VariableControla
--lectico-accentColor de acento: botón, foco, asterisco de campo obligatorio. Por defecto, el primaryColor de tu marca.
--lectico-textColor del texto principal.
--lectico-button-bgFondo del botón de envío.
--lectico-button-textColor del texto del botón.
--lectico-borderColor del borde de los campos.
--lectico-radiusRadio de las esquinas (tarjeta y campos).
--lectico-max-widthAncho máximo del formulario.
--lectico-fontFamilia tipográfica.
--lectico-bgFondo de la tarjeta.
--lectico-paddingPadding interior de la tarjeta.
--lectico-shadowSombra de la tarjeta.
--lectico-labelColor de las etiquetas de campo.
--lectico-helpColor del texto de ayuda.

Parts

Apunta a partes internas del formulario con ::part(<nombre>):

form · field · label · input · select · submit · footer · success · error · title

Modo "bare" (sin tarjeta)

Si tu página ya aporta el contenedor (fondo, borde, sombra), usa appearance="bare" para que el formulario no pinte su propia tarjeta y evites el efecto "dos tarjetas apiladas":

<lectico-form api-key="pk_live_xxx" form-id="frm_xxx" appearance="bare"></lectico-form>

Quita fondo, sombra, radio y padding. El host puede re-añadir cualquiera vía las CSS vars (p. ej. lectico-form { --lectico-padding: 16px }).

Ancho fluido

Por defecto el formulario es fluido (width: 100%, max-width: var(--lectico-max-width, 100%)). El atributo width acepta fluid (o full / 100%), un número como 480 o una longitud CSS (640px, 48rem). Para fijar un ancho usa width="480" o width="640px" en lugar de luchar contra el shadow DOM con !important.

Eventos de ciclo de vida

El web component emite CustomEvents (con bubbles: true) en cada fase. Útiles para mostrar tu propio loader de marca, instrumentar analytics o reaccionar al envío.

EventoCuándodetail
lectico-form:loadingEmpieza a cargar la config{ formId }
lectico-form:readyCampos pintados (primera vez){ formId, fields }
lectico-form:successSubmit OKCuerpo de la respuesta ({ data: { ticket: { public_id } } })
lectico-form:errorFallo de carga o de submit{ formId, phase, message }phase es "config" o "submit"
const form = document.querySelector("lectico-form");

form.addEventListener("lectico-form:loading", () => showMyLoader());
form.addEventListener("lectico-form:ready",   () => hideMyLoader());

form.addEventListener("lectico-form:success", (e) =>
  console.log("Ticket:", e.detail?.data?.ticket?.public_id));

form.addEventListener("lectico-form:error", (e) =>
  console.warn("Error:", e.detail?.phase, e.detail?.message));

Ejemplo: loader propio

<div id="mi-loader" hidden>Cargando formulario…</div>
<lectico-form id="contacto" api-key="pk_live_xxx" form-id="frm_xxx"></lectico-form>

<script>
  const loader = document.getElementById("mi-loader");
  const form = document.getElementById("contacto");
  form.addEventListener("lectico-form:loading", () => { loader.hidden = false; });
  form.addEventListener("lectico-form:ready",   () => { loader.hidden = true; });
  form.addEventListener("lectico-form:error",   (e) => {
    loader.hidden = true;
    if (e.detail.phase === "config") loader.textContent = "No se pudo cargar el formulario.";
  });
</script>

Headless (tu propia UI)

Si tienes un sistema de diseño fuerte, monta tu formulario y envía al mismo endpoint. La identidad firmada también aplica aquí.

Helpers JS

Si cargas el bundle del widget tienes helpers en window.Lectico.form:

// Leer el esquema del formulario
const config = await window.Lectico.form.getConfig({
  apiKey: "pk_live_xxx",
  formId: "frm_xxx",
});

// Enviar la submission
const res = await window.Lectico.form.submit({
  apiKey: "pk_live_xxx",
  formId: "frm_xxx",
  fields: { name: "Ada", email: "ada@x.com", subject: "tech", body: "Hola" },
  identity: "<JWT_OPCIONAL>",
});
// res → { data: { ticket: { public_id } } }

Fetch crudo a los dos endpoints

Sin el bundle, llama directamente a los dos backends (ver Dos backends):

// 1) (opcional) leer el esquema del formulario — api.lectico.com
const config = await fetch(
  "https://api.lectico.com/v1/forms/frm_xxx/config",
  { headers: { "Lectico-Api-Key": "pk_live_xxx" } },
).then((r) => r.json());

// 2) enviar la submission — forms.lectico.com
const res = await fetch(
  "https://forms.lectico.com/api/forms/frm_xxx/submit",
  {
    method: "POST",
    headers: { "Content-Type": "application/json", "Lectico-Api-Key": "pk_live_xxx" },
    body: JSON.stringify({
      fields: { name: "Ada", email: "ada@x.com", subject: "tech", body: "Hola" },
      identity_token: "<JWT_OPCIONAL>",
    }),
  },
);
const out = await res.json(); // { data: { ticket: { public_id } } }
CódigoSignificado
200 / 201Submission creada. El ticket viene en data.ticket.public_id.
204Honeypot activado (bot detectado). Se devuelve éxito silencioso.
400Errores de validación. Detalle en error.field_errors[] ({ field, message }).
401Identidad requerida ausente o inválida (formulario con verificación required).
429Rate-limit. Reintenta tras unos minutos.

Rendimiento

El widget está optimizado para pintar rápido y no penalizar tu Largest Contentful Paint:

  • Skeleton — pinta un esqueleto con shimmer en el shadow DOM mientras carga la config (sin "pop-in" de tarjeta en blanco).
  • Caché de esquema — cachea la config en sessionStorage y revalida con ETag. En navegaciones posteriores dentro de la sesión el formulario aparece al instante.
  • Preconnect — adelanta el handshake TLS añadiendo estas dos líneas en tu <head>:
<link rel="preconnect" href="https://api.lectico.com" crossorigin>
<link rel="preconnect" href="https://forms.lectico.com" crossorigin>

Componente React drop-in

import { useEffect } from "react";

const SRC =
  "https://api.lectico.com/storage/v1/object/public/widget/v1/lectico-form-element.esm.js";

export function SupportForm({ user, token }) {
  useEffect(() => {
    if (document.querySelector('script[data-lectico-widget="v1"]')) return;
    const s = document.createElement("script");
    s.type = "module";
    s.src = SRC;
    s.dataset.lecticoWidget = "v1";
    document.head.appendChild(s);
  }, []);

  return (
    // @ts-expect-error custom element
    <lectico-form
      api-key="pk_live_xxx"
      form-id="frm_xxx"
      locale="es"
      prefill-name={user.name}
      prefill-email={user.email}
      hidden-fields="name,email"
      identity={token}
    />
  );
}

token es el JWT HS256 que firma tu backend (ver Identidad firmada). Con prefill-* + hidden-fields el usuario logueado no reescribe sus datos y el ticket queda correctamente atribuido.

Seguridad

  • El secreto de identidad es por workspace, vive cifrado server-side y nunca se expone en /config ni al cliente. Fírmalo siempre en tu backend.
  • La identidad verificada hace override (no append) del nombre/email: una submission firmada no se puede atribuir a otra persona desde el cliente.
  • Usa un exp corto en el token. La verificación de la firma es constant-time (timing-safe).
  • El submit pasa por validación server-side (Zod, honeypot, rate-limit, comprobación de origen): la validación del cliente es solo feedback de UX.

Próximos pasos