Conexión de formularios WordPress + recogida de UTMs
Cuando un cliente se da de alta, hay que resolver dos cosas distintas en su WordPress, no una:
- Transporte: que el envío del formulario llegue al webhook de LeadRoom con URL + token.
- Datos: que ese envío lleve los UTMs y clicks IDs (
gclid,fbclid, etc.) para que el motor de clasificación pueda asignar una fuente. Sin esto, todo cae en «Organic» aunque el lead venga de una campaña pagada.
El motor de clasificación ya existe y espera nombres de campo concretos. Este documento resuelve exclusivamente el «cómo llega el dato desde el formulario de WordPress hasta el webhook», que es la parte que hoy hace soporte a mano y sin un procedimiento repetible.
Principio: estandarizar, no resolver caso por caso
Hay demasiados constructores de formularios distintos (Elementor, CF7, WPForms, Gravity Forms, Fluent Forms, Ninja Forms, Divi, Forminator…) como para que soporte aprenda una integración distinta por cada uno. La forma más sencilla de escalar esto es fijar dos piezas estándar que se instalan igual en todos los sitios, y usarlas por defecto salvo que el plugin del cliente tenga soporte nativo mejor (tabla más abajo).
Pieza A — snippet de captura de UTMs. Un único script JS, mismo para todos los clientes, que lee UTMs/click IDs de la URL, los persiste (para sobrevivir a una navegación entre páginas) y rellena cualquier campo oculto que coincida por nombre. Se instala en segundos con un plugin de fragmentos de código (WPCode, gratuito) — cero edición de tema, sobrevive a actualizaciones.
Pieza B — plugin puente de webhooks. WP Webhooks (hay versión gratuita en WordPress.org) es compatible como «disparador» con CF7, Elementor, WPForms, Gravity Forms, Fluent Forms, Ninja Forms y varios más, con el mismo procedimiento en todos: activar → crear un «envío de webhook» → elegir el plugin/formulario de origen → pegar URL + token de LeadRoom → mapear campos. Esto evita tener que aprender una integración nativa distinta por cliente cuando el formulario no la trae de serie.
Con A + B, el mismo procedimiento cubre prácticamente cualquier sitio WordPress que llegue, sin depender de qué constructor use ese cliente en concreto.
Soporte nativo por plugin (cuándo se puede saltar A y/o B)
Algunos constructores ya traen parte de esto de serie. Conviene usarlo cuando existe, porque es más rápido y hay una pieza menos que mantener:
| Plugin | Webhook nativo | Autorrelleno de campo por URL nativo | Recomendación |
|---|---|---|---|
| Gravity Forms | Sí — Webhooks Add-On oficial, incluido en toda licencia | Sí — «Allow field to be populated dynamically» | Usar nativo para ambas cosas. No hace falta ni A ni B. |
| Fluent Forms | Sí — integración de Webhook nativa en el propio plugin | Sí — Smart Codes con parámetro de URL | Usar nativo para ambas cosas. No hace falta ni A ni B. |
| Elementor Pro | No hay acción de webhook genérica de serie (solo integra con servicios de marketing concretos y Zapier) | Sí — Dynamic Tag «Request Parameter» para el valor por defecto de un campo | Usar el Dynamic Tag nativo para poblar campos si el formulario vive en la misma página donde aterriza el tráfico; usar B para el transporte al webhook. |
| WPForms | Solo vía addon oficial «Webhooks», exclusivo del plan Elite | Sí — Smart Tags con query string, disponible en planes inferiores | Si el cliente tiene Elite: nativo para todo. Si no: nativo para poblar campos + B para el transporte. |
| Contact Form 7 | No | No | A + B obligatorio — CF7 no resuelve ninguna de las dos cosas por sí solo. |
| Ninja Forms | No de serie (extensión de pago) | Parcial, vía addon | A + B por defecto, salvo que el cliente ya tenga la extensión de pago instalada. |
| Divi Builder / Forminator / otros | Generalmente no | Generalmente no | A + B (tratamiento genérico). |
Aviso importante sobre el autorrelleno nativo por URL (Elementor, WPForms, Gravity Forms): solo lee la query string de la página actual. Si el tráfico pagado aterriza en una landing y el formulario está en otra página (ej. /contacto), el parámetro ya no está en la URL cuando el usuario llega ahí — el autorrelleno nativo falla en silencio. Por eso, en cualquier cliente donde el formulario no esté siempre en la misma URL que la landing de aterrizaje, hay que usar el snippet (Pieza A), que persiste el dato entre páginas. El método nativo por URL solo es fiable cuando el propio landing page ya contiene el formulario.
Pieza A en detalle — snippet de captura y persistencia de UTMs
Captura al cargar cualquier página: utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, gbraid, wbraid, ttclid, li_fat_id, msclkid, fbclid — estos son los nombres canónicos, deben coincidir exactamente con lo que espera el webhook (confirmado leyendo el código del endpoint, ver sección «Contrato del webhook» más abajo). Los guarda en sessionStorage (con fallback a cookie de 30 días si se necesita que sobreviva a cierre de pestaña) y rellena cualquier <input type="hidden"> cuyo name coincida, incluyendo formularios que se renderizan después vía AJAX (popups, multi-step).
<script>
(function () {
var CAMPOS = ['utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'gclid','gbraid','wbraid','ttclid','li_fat_id','msclkid','fbclid'];
var STORAGE_KEY = 'leadroom_utms';
function leerParams() {
var params = new URLSearchParams(window.location.search);
var datos = {};
CAMPOS.forEach(function (campo) {
var valor = params.get(campo);
if (valor) datos[campo] = valor;
});
return datos;
}
function guardar(datos) {
if (Object.keys(datos).length === 0) return;
var actual = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
var combinado = Object.assign({}, actual, datos); // nuevos UTMs pisan a los antiguos
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(combinado));
document.cookie = 'leadroom_utms=' + encodeURIComponent(JSON.stringify(combinado)) +
';path=/;max-age=' + (30 * 24 * 60 * 60);
}
function leerGuardado() {
var deSession = sessionStorage.getItem(STORAGE_KEY);
if (deSession) return JSON.parse(deSession);
var match = document.cookie.match(/leadroom_utms=([^;]+)/);
return match ? JSON.parse(decodeURIComponent(match[1])) : {};
}
function rellenarCampos() {
var datos = leerGuardado();
if (Object.keys(datos).length === 0) return;
CAMPOS.forEach(function (campo) {
if (!datos[campo]) return;
document.querySelectorAll('input[name="' + campo + '"]').forEach(function (input) {
input.value = datos[campo];
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
});
});
}
guardar(leerParams());
rellenarCampos();
// Reintenta si el formulario se renderiza después (popups Elementor, multi-step, AJAX)
var observer = new MutationObserver(function () { rellenarCampos(); });
observer.observe(document.body, { childList: true, subtree: true });
})();
</script>
Instalación por cliente: plugin WPCode (gratuito) → «Add Snippet» → tipo HTML → ubicación «Footer» → pegar el bloque anterior tal cual → activar. Es el mismo paso, siempre, sin tocar el theme.
Pieza B en detalle — WP Webhooks como puente universal
Instalación por cliente (cuando el plugin de formularios no tiene webhook nativo — ver tabla):
-
- Instalar y activar WP Webhooks (versión gratuita cubre «enviar datos al enviar un formulario»; la de pago añade filtros/transformaciones si algún cliente lo necesita).
- En WP Webhooks → Send Data → elegir la integración correspondiente al plugin del cliente (CF7, Elementor, Ninja Forms…).
- Activar el disparador para el formulario concreto que se quiere conectar.
- Pegar la URL del webhook de LeadRoom y el token de esa organización (se obtienen en
/organizacion/configuracion). El endpoint confirmado esPOST https://leadroomcrm.com/api/webhooks/wordpress, y el token puede ir como query param en esa misma URL (?token=..., el método por defecto de las conexiones de WordPress) o como headerX-Webhook-Tokensi WP Webhooks lo permite — cualquiera de los dos vale. Comprobar que WP Webhooks mande el body como JSON (noform-urlencoded): es su comportamiento habitual, pero conviene confirmarlo con la prueba de humo, porque el webhook rechaza cualquier body que no sea JSON válido. - Mapear cada campo del formulario (incluidos los ocultos de UTM que añadió la Pieza A) al nombre de campo que espera LeadRoom — ver tabla de mapeo abajo.
Mapeo de campos
Confirmado leyendo el código del endpoint (ver «Contrato del webhook» más abajo, dentro de la Guía para desarrollo): los campos de UTM/click IDs se reconocen por nombre exacto, los campos del lead (nombre, email, teléfono…) se reconocen por patrón flexible (no hace falta el nombre exacto, pero conviene usarlo igualmente por claridad).
| Campo oculto en el formulario | Clave esperada en el payload del webhook |
|---|---|
utm_source |
utm_source |
utm_medium |
utm_medium |
utm_campaign |
utm_campaign |
utm_term |
utm_term |
utm_content |
utm_content |
gclid / gbraid / wbraid |
igual, sin transformar |
ttclid, li_fat_id, msclkid, fbclid |
igual, sin transformar |
Si el nombre de un campo de UTM/click ID no coincide exactamente, el webhook no lo reconoce (a diferencia de nombre/email/teléfono, que sí tienen tolerancia) y el lead cae en «Organic» o en el fallback aunque el resto del dato haya llegado.
Checklist de onboarding para soporte
-
- Preguntar (o detectar mirando el código fuente / plugins activos) qué constructor de formularios usa el sitio.
- Consultar la tabla de soporte nativo: ¿el plugin resuelve transporte y/o autorrelleno de serie?
- Instalar Pieza A (snippet WPCode) siempre, salvo que el cliente esté 100% en Gravity Forms/Fluent Forms con formulario en la misma página que la landing.
- Si el plugin no tiene webhook nativo: instalar y configurar Pieza B (WP Webhooks) apuntando al webhook + token de esa organización.
- Abrir cada formulario a conectar en el editor, añadir los campos ocultos de UTM/click IDs con los nombres exactos de la tabla de mapeo.
- Mapear esos campos en la configuración del webhook (nativo o WP Webhooks) a las claves esperadas por LeadRoom.
- Prueba de humo: enviar un lead de prueba con una URL que lleve
?utm_source=test&utm_medium=test&utm_campaign=onboarding, confirmar que aparece en/organizacion/leadsconfuente_clasificada = "test + test"(fallback esperado, confirma que el dato crudo llegó) ymetodo_ingreso = webhook. - Repetir la prueba simulando el caso real: aterrizar en la landing con UTMs, navegar a la página del formulario, enviar — para confirmar que la persistencia entre páginas funciona, no solo el caso de formulario-en-la-misma-página.
- Marcar el alta como completa solo después del paso 8, no del 7.
Caso difícil: constructor no soportado por WP Webhooks
Si aparece un plugin de formularios muy minoritario sin integración en WP Webhooks, la opción más fiable sigue siendo un envío servidor a servidor: un fragmento PHP mínimo (vía wp_remote_post, enganchado al hook de submit del plugin en cuestión, cargado por ejemplo como «PHP Snippet» con WPCode) que arma el JSON y llama al webhook desde el propio WordPress. Esto evita por completo el problema de CORS del endpoint (no tiene Access-Control-Allow-Origin configurado — ver «Contrato del webhook» en la Guía para desarrollo, más abajo).
Como último recurso, si no es viable tocar PHP, cabe un listener de JS que intercepta el submit de un <form> y hace fetch() directo al webhook desde el navegador, en paralelo al envío normal del formulario — pero al no haber CORS configurado, una petición con Content-Type: application/json dispara preflight y falla. Sortearlo es un workaround frágil (además expone el token en el navegador), así que debe quedar como último recurso, no como método por defecto — para el ~95% de los casos, A + B, el nativo de la tabla, o un envío server-side, es suficiente.
Guía para el equipo de desarrollo del cliente (formularios a medida)
Piezas A y B asumen que hay un plugin de formularios de por medio (Elementor, CF7…) al que engancharse. Cuando el cliente tiene un formulario desarrollado a medida (HTML/JS propio, un framework headless, un tema custom que no usa ningún plugin de formularios reconocible), no hay dónde instalar WP Webhooks ni un builder con autorrelleno nativo — es el propio equipo de desarrollo del cliente quien tiene que implementar las dos capas en su código. Esta sección es la ficha que se le puede pasar directamente a ese desarrollador, sin pasar por soporte.
Cuándo aplica esta vía en vez de A + B: solo cuando el formulario no está gestionado por ningún plugin de formularios (el HTML del <form> está escrito a mano o generado por el propio tema/código del cliente). Si el formulario visualmente parece «custom» pero por debajo sigue siendo un formulario de Elementor/CF7/etc. con estilos propios, siguen aplicando A + B tal cual — se puede confirmar mirando si el plugin en cuestión aparece activo en wp-admin/plugins.php.
Lo que tiene que implementar
-
- Capturar y persistir UTMs/click IDs. El mismo snippet de la Pieza A es JS puro, sin ninguna dependencia de WordPress — se puede pegar tal cual en su
<head>/antes de cerrar<body>, o portar a su framework si usan uno (React, Vue…). No hace falta reescribirlo, solo asegurarse de que corre en todas las páginas del sitio, no solo en la que tiene el formulario. - Enviar el payload al webhook de LeadRoom en el submit del formulario (desde su backend si el formulario se procesa server-side, o desde el navegador si es un sitio estático/serverless).
- Capturar y persistir UTMs/click IDs. El mismo snippet de la Pieza A es JS puro, sin ninguna dependencia de WordPress — se puede pegar tal cual en su
Contrato del webhook (confirmado)
Esto ya está confirmado, leído directamente del código del endpoint en el backend de LeadRoom (/api/webhooks/wordpress). Es la ficha que se le puede pasar tal cual a un desarrollador externo.
Endpoint y autenticación
POST https://leadroomcrm.com/api/webhooks/wordpress?token={webhook_token}
El token acepta tres formas alternativas (basta con usar una):
-
- Header
X-Webhook-Token: {webhook_token}— recomendado para una integración propia, mantiene el token fuera de la URL (fuera de logs de acceso, historial, etc.). - Campo
webhook_tokendentro del body JSON. - Query param
?token={webhook_token}en la URL — es el método que usan por defecto las conexiones de WordPress ya montadas (WP Webhooks, plugins nativos).
- Header
El token es único por organización, se obtiene en Configuración → Webhook del CRM (/organizacion/configuracion).
Hay además un GET /api/webhooks/wordpress de health check, sin autenticar, que devuelve 200 { "status": "LeadRoom webhook activo" } — útil como primera comprobación de que la URL es correcta antes de montar la integración completa.
Formato del body
El endpoint intenta parsear el body como JSON siempre, sin mirar el header Content-Type que se declare. En la práctica: el body tiene que ser JSON válido, sea cual sea el Content-Type que se mande. No acepta multipart/form-data. Un application/x-www-form-urlencoded real (a=1&b=2) no es JSON válido y cae en el error genérico 500 de más abajo.
Esto tiene una implicación directa para un formulario a medida: no vale un <form action="..." method="post"> nativo sin JavaScript — el navegador lo mandaría como x-www-form-urlencoded, no como JSON. Hace falta interceptar el submit con JS y mandar el body con fetch/XHR serializado con JSON.stringify(...).
Ningún campo es obligatorio. Un {} vacío crea igualmente un lead válido («Sin nombre», el resto a null). El endpoint no devuelve error 4xx por «falta este campo» — que el formulario mande algo útil corre por cuenta del desarrollador del cliente, no lo fuerza el webhook.
Campos del lead — reconocimiento por patrón, no por nombre exacto. Para nombre/email/teléfono/empresa/mensaje, el backend no exige la clave exacta: reconoce por patrón (sin acentos, minúsculas) sobre el nombre de cada clave del JSON:
| Campo interno | Patrón que reconoce la clave | Ejemplos de claves válidas |
|---|---|---|
| Nombre | nombre|name|apellido|contacto|persona|quien |
nombre, name, apellido |
email|correo|mail |
email, correo |
|
| Teléfono | telefono|movil|phone|tel|celular|llamar|contactar|whatsapp |
telefono, phone, whatsapp |
| Empresa | empresa|compania|negocio|organization|company |
empresa, company |
| Mensaje | mensaje|message|consulta|comentario|descripcion|informacion|detalle |
mensaje, message |
Solo se asigna la primera clave que matchea cada patrón; una segunda clave que case el mismo slot (ej. nombre y apellido casan ambos «Nombre») no se pierde, pero tampoco se guarda estructurada: queda en metadata._unmatched, igual que cualquier clave que no case ningún patrón. Por eso, aunque el reconocimiento sea flexible, seguimos recomendando usar exactamente nombre, email, telefono, empresa, mensaje — evita duplicados accidentales y hace el payload predecible de leer.
Campos de UTM/click IDs — por nombre exacto, sin patrón. A diferencia de los anteriores, tienen que llamarse literalmente así (todos opcionales): utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, gbraid, wbraid, ttclid, li_fat_id, msclkid, fbclid.
Nota: utm_content no aparece en LeadRoom_CRM_Logica_UTMs.md (ese documento solo lista utm_source, utm_medium, utm_campaign, utm_term como capturados) pero el webhook sí lo reconoce como campo aceptado. Queda como pregunta abierta para el equipo de backend si se está guardando y para qué se usa — ver «Pendiente de definir».
Además del formato «plano» de arriba (el recomendado para CF7 y formularios a medida), el endpoint también reconoce automáticamente otros tres formatos de body que llegan tal cual desde ciertos plugins/integraciones, sin necesidad de transformarlos: un array de campos {fields: [{label, value}]}, un objeto de campos {fields: {"1": {name, value}}}, y el formato anidado propio de Ninja Forms. Si al conectar Elementor o WPForms por WP Webhooks resulta más simple reenviar la estructura nativa del plugin en vez de mapear campo a campo, puede valer la pena probarlo — pero conviene confirmarlo con un envío de prueba real, porque no hay una confirmación explícita de qué integración concreta genera cada uno de esos formatos.
Respuestas
| Caso | HTTP | Body |
|---|---|---|
| Éxito | 201 | { "success": true, "lead_id": "<uuid>" } |
| Sin token | 401 | { "error": "Token requerido" } |
| Token no coincide con ninguna organización | 401 | { "error": "Token inválido" } |
| Error al insertar en BD | 500 | { "error": "<mensaje crudo de la base de datos>" } |
| Body no es JSON válido / excepción no controlada | 500 | { "error": "Error procesando webhook" } |
CORS y rate limiting. No hay configuración de CORS (sin header Access-Control-Allow-Origin, sin handler OPTIONS) ni rate limiting a nivel de aplicación. En la práctica:
-
- Una petición
fetch()conContent-Type: application/jsonhecha directamente desde JS del navegador contra un dominio distinto al de LeadRoom dispara preflight y falla (405, sin handlerOPTIONS). El endpoint está pensado para llamarse servidor a servidor (WordPress backend víawp_remote_post, cURL, otro backend), no directamente desde JS de navegador en un dominio externo. WP Webhooks ya funciona así — su «Send Data» corre en PHP en el servidor de WordPress, no en el navegador del visitante, así que no le afecta esta limitación. - Para un formulario a medida sin backend propio (sitio estático/serverless), ver la nota de CORS en «Caso difícil» más abajo.
- No hay límite de tasa a nivel de aplicación — ver «Seguridad del token».
- Una petición
Resumen para copiar y pegar
Endpoint: POST https://leadroomcrm.com/api/webhooks/wordpress?token={webhook_token}
Alternativa: header X-Webhook-Token: {webhook_token} (sin el query param)
Content-Type: el body SIEMPRE debe ser JSON válido (no importa el header declarado;
no se acepta multipart/form-data; un <form> nativo sin JS no sirve)
Payload de ejemplo:
{
"nombre": "...",
"email": "...",
"telefono": "...",
"empresa": "...",
"mensaje": "...",
"utm_source": "...",
"utm_medium": "...",
"utm_campaign": "...",
"utm_term": "...",
"utm_content": "...",
"gclid": "...",
"gbraid": "...",
"wbraid": "...",
"ttclid": "...",
"li_fat_id": "...",
"msclkid": "...",
"fbclid": "..."
}
Éxito: 201 { "success": true, "lead_id": "<uuid>" }
Error: 401 (sin token / token inválido) o 500 (JSON inválido / error de BD)
Ningún campo es obligatorio — se puede omitir cualquiera que no aplique al formulario en concreto.
Recomendaciones para el desarrollador
-
- Body siempre JSON, enviado por JS — con
fetch/XHRyJSON.stringify(...), nunca un<form action="..." method="post">nativo sin interceptar (se mandaría comox-www-form-urlencodedy el webhook lo rechazaría). - Mejor desde su backend que desde el navegador, cuando el cliente tenga uno propio: evita exponer el token en JS público, evita el problema de CORS descrito arriba, y permite loguear/reintentar si el webhook responde 500. Si el sitio es estático sin backend propio, esta llamada servidor a servidor no es posible y toca hacerlo desde el navegador — en ese caso el token queda expuesto en el código fuente igual que en el fallback genérico (ver «Seguridad del token»), y conviene comprobar con un envío de prueba real si el preflight bloquea la petición en su caso concreto.
- No bloquear la experiencia del usuario a que el webhook responda: mostrar el mensaje de éxito del formulario en cuanto el lead se guarde por su vía normal (email, base de datos propia…), y mandar el envío a LeadRoom en paralelo, no como requisito para confirmar el envío al usuario.
- Los campos de UTM/click IDs van con el nombre exacto de la lista de arriba — a diferencia de nombre/email/teléfono/empresa/mensaje, estos no tienen reconocimiento por patrón; cualquier variación hace que el motor de clasificación no los reconozca y el lead caiga en «Organic» o fallback aunque el dato haya llegado.
- Comprobar la respuesta: 201 con
lead_ides éxito; 401 es problema de token (revisar dónde se está mandando); 500 puede ser JSON mal formado o un error de la base de datos — vale la pena loguear el body de la respuesta durante las pruebas, aunque no se muestre al usuario final. - Probarlo igual que en el checklist de soporte (pasos 7 y 8 más arriba): primero un
GETal endpoint para confirmar que la URL responde, luego un envío de prueba con UTMs de test, y por último simulando landing → navegación → formulario, para confirmar que la persistencia entre páginas también funciona en su implementación custom.
- Body siempre JSON, enviado por JS — con
¿Tienes dudas?
Contacta con nuestro equipo de soporte ahora