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_xxxxxxxxxxxxapi-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ón | Endpoint | Host | Header de auth |
|---|---|---|---|
| Cargar esquema (config) | GET /v1/forms/:id/config | api.lectico.com | Lectico-Api-Key: pk_live_… |
| Enviar respuesta (submit) | POST /api/forms/:id/submit | forms.lectico.com | Lectico-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.
| Atributo | Req. | Valores | Descripción |
|---|---|---|---|
api-key | sí | pk_live_… | Clave pública del widget. |
form-id | sí | frm_… | ID público del formulario. |
locale | no | es / en | Idioma de los textos del formulario. Por defecto se autodetecta (lang del documento → idioma del navegador → es). |
prefill-name | no | texto | Rellena el campo name. |
prefill-email | no | texto | Rellena el campo email. |
prefill | no | JSON | Prefill de varios campos por id, p. ej. prefill='{"plan":"pro"}'. |
hidden-fields | no | CSV de ids | Campos a ocultar. Sus valores se envían igual. |
identity | no | JWT HS256 | Token de identidad firmada (Pro+). Ver Identidad firmada. |
width | no | fluid / nº / longitud CSS | Ancho 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. |
appearance | no | card / bare | card (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:
| Claim | Req. | Tipo | Descripción |
|---|---|---|---|
email | sí | string | Email autoritativo del usuario. |
name | no | string | Nombre del usuario. |
sub | no | string | ID interno del usuario en tu sistema. |
iat | sí | number | Emitido en (segundos epoch). |
exp | sí | number | Expira 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/namesustituyen 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
| Variable | Controla |
|---|---|
--lectico-accent | Color de acento: botón, foco, asterisco de campo obligatorio. Por defecto, el primaryColor de tu marca. |
--lectico-text | Color del texto principal. |
--lectico-button-bg | Fondo del botón de envío. |
--lectico-button-text | Color del texto del botón. |
--lectico-border | Color del borde de los campos. |
--lectico-radius | Radio de las esquinas (tarjeta y campos). |
--lectico-max-width | Ancho máximo del formulario. |
--lectico-font | Familia tipográfica. |
--lectico-bg | Fondo de la tarjeta. |
--lectico-padding | Padding interior de la tarjeta. |
--lectico-shadow | Sombra de la tarjeta. |
--lectico-label | Color de las etiquetas de campo. |
--lectico-help | Color 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.
| Evento | Cuándo | detail |
|---|---|---|
lectico-form:loading | Empieza a cargar la config | { formId } |
lectico-form:ready | Campos pintados (primera vez) | { formId, fields } |
lectico-form:success | Submit OK | Cuerpo de la respuesta ({ data: { ticket: { public_id } } }) |
lectico-form:error | Fallo 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ódigo | Significado |
|---|---|
200 / 201 | Submission creada. El ticket viene en data.ticket.public_id. |
204 | Honeypot activado (bot detectado). Se devuelve éxito silencioso. |
400 | Errores de validación. Detalle en error.field_errors[] ({ field, message }). |
401 | Identidad requerida ausente o inválida (formulario con verificación required). |
429 | Rate-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
sessionStoragey revalida conETag. 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
/configni 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
expcorto 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.