Formulario de captación de leads7 min de lectura
Envía los leads de un formulario a HubSpot o Pipedrive
Convierte cada webhook firmado de Ikiform en el upsert de contactos de HubSpot o en las llamadas de personas y leads de Pipedrive, sin contactos duplicados cuando se reintenta una entrega.
Apunta un webhook de Ikiform a un pequeño relay que verifique la firma y traslade el lead a la API de tu CRM: el upsert por lotes de HubSpot con el email como clave, o la búsqueda de personas, la creación de personas y la creación de leads de Pipedrive. El cuerpo del webhook de Ikiform es fijo, así que el relay hace la conversión. Haz que cada paso se pueda repetir sin problemas, porque una entrega fallida se reintenta con el mismo webhook-id.
Qué tiene de distinto enviar leads a un CRM
No hay integraciones de un clic. Cada herramienta se conecta mediante un webhook firmado, directamente o a través de una herramienta de automatización como Zapier o n8n. El cuerpo del webhook es el mismo para todos los destinos: data.fields contiene los valores del formulario, y data.meta contiene el origen, el referrer, el país y la dirección de respuesta. Ikiform no te deja cambiar su forma, así que algo intermedio lo convierte en la llamada que espera tu CRM.
Dos cosas hacen que un CRM sea más difícil que un canal de chat:
- Un CRM guarda registros, no mensajes. Publicar el mismo lead dos veces en Slack es ruido. Crear el mismo contacto dos veces es un lío que alguien tiene que fusionar a mano. El relay debe buscar antes de crear.
- Los reintentos son parte del contrato. Si tu relay o el CRM fallan, Ikiform reintenta la entrega durante unos 3 días con el mismo
webhook-id. Un relay que a la primera tuvo éxito a medias no debe duplicar nada a la segunda.
Relay o herramienta de automatización
| Opción | Punto fuerte | Inconvenientes |
|---|---|---|
| Zapier, Make o n8n | Pasos de HubSpot y Pipedrive ya hechos, con acciones de buscar o crear y sin código | Precio por tarea, y una cuenta más que guarda las credenciales de tu CRM |
| Tu propio relay | Correspondencia de campos exacta, tus propias reglas de duplicados, nada facturado por lead | Lo despliegas y lo vigilas tú. Unas 60 líneas por CRM, como se ve abajo |
En ambos casos, la configuración del webhook es la misma: añade el endpoint en Webhooks, copia su secreto whsec_ y envía una prueba. La documentación de webhooks cubre el payload y los reintentos, y tu propio servidor explica con más detalle cómo verificar las entregas.
HubSpot: upsert del contacto por email
HubSpot trata el email como identificador único de un contacto, y su endpoint de upsert por lotes acepta idProperty: "email". Una sola llamada crea el contacto si la dirección es nueva y lo actualiza si no, así que un reintento no puede crear un duplicado.
- fields.email, en minúsculas, pasa a ser email y la clave del upsert.
- fields.name, la primera palabra, pasa a ser firstname.
- fields.name, el resto, pasa a ser lastname.
- fields.company pasa a ser company.
- fields.interest pasa a ser ikiform_interest, una propiedad que creas tú.
- HubSpot asigna el propietario y la etapa del ciclo de vida con sus propias reglas; el relay no los toca.
Configuración
- En HubSpot, crea una private app (en algunas cuentas HubSpot ya las clasifica como legacy apps) con el scope
crm.objects.contacts.writey copia su token de acceso enHUBSPOT_TOKEN. - Crea una propiedad de contacto con el nombre interno
ikiform_interest, o quita esa línea. HubSpot rechaza las propiedades que no existen. - Despliega la ruta de abajo y añade su URL como endpoint de webhook en Ikiform.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const UPSERT = "https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert";
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const fields = event.data.fields;
const email = String(fields.email ?? "").trim().toLowerCase();
if (!email) return new Response("No email, nothing to upsert", { status: 200 });
const [firstname = "", ...rest] = String(fields.name ?? "").trim().split(/\s+/);
const response = await fetch(UPSERT, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({
inputs: [
{
idProperty: "email",
id: email,
properties: {
email,
firstname,
lastname: rest.join(" "),
company: String(fields.company ?? ""),
phone: String(fields.phone ?? ""),
ikiform_interest: String(fields.interest ?? ""),
},
},
],
}),
});
if (response.status === 429) {
return new Response("HubSpot rate limit", { status: 429, headers: { "retry-after": "10" } });
}
if (response.status === 207 || !response.ok) {
return new Response(await response.text(), { status: 502 });
}
return new Response(null, { status: 204 });
}- Un
429de HubSpot se convierte en un429conRetry-After, que Ikiform respeta antes de volver a intentarlo. - Cualquier otro fallo, incluido un
207con errores por registro, se convierte en un502con la respuesta de HubSpot como cuerpo. Los primeros 4 KB quedan en el registro de entregas de Ikiform, así que puedes leer el mensaje de HubSpot sin tus propios logs. - El upsert envía todas las propiedades cada vez, porque HubSpot no admite upserts parciales por email. Un
companyvacío en un segundo formulario sobrescribe uno conocido; quita los valores vacíos depropertiessi eso te importa.
v3 en su sección legacy. Consulta la referencia actual de contactos cuando lo construyas y mantén igual el cuerpo de la petición.Pipedrive: busca la persona y luego añade un lead
Pipedrive separa las personas de los leads. Un lead tiene que estar vinculado a una persona o a una organización, así que el relay busca o crea la persona y luego añade un lead con un título. Las personas y la búsqueda de personas usan la API v2; los leads y las notas son endpoints v1. Ambos reciben tu token de API en la cabecera x-api-token.
- Llega una entrega con su webhook-id.
- El relay busca ese id en un almacén clave-valor.
- Si ya se procesó, el relay responde 200 y se detiene.
- Si no, busca personas por email exacto y crea una solo cuando no hay ninguna coincidencia.
- Añade un lead vinculado a esa persona, luego una nota con el mensaje, y registra el webhook-id.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
interface Env {
IKIFORM_WEBHOOK_SECRET: string;
PIPEDRIVE_TOKEN: string;
PIPEDRIVE_DOMAIN: string;
SEEN: KVNamespace;
}
export default {
async fetch(request: Request, env: Env) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, headers: request.headers, secret: env.IKIFORM_WEBHOOK_SECRET });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
if (await env.SEEN.get(event.id)) return new Response("Already handled", { status: 200 });
const base = `https://${env.PIPEDRIVE_DOMAIN}.pipedrive.com/api`;
const headers = { "x-api-token": env.PIPEDRIVE_TOKEN, "content-type": "application/json" };
const call = async (path: string, init?: RequestInit) => {
const response = await fetch(`${base}${path}`, { ...init, headers });
if (!response.ok) throw new Error(`Pipedrive ${path} answered ${response.status}: ${await response.text()}`);
return (await response.json()) as { data: any };
};
const fields = event.data.fields;
const email = String(fields.email ?? "").trim().toLowerCase();
const name = String(fields.name ?? email);
try {
const found = await call(`/v2/persons/search?term=${encodeURIComponent(email)}&fields=email&exact_match=true`);
let personId: number | undefined = found.data?.items?.[0]?.item?.id;
if (!personId) {
const created = await call("/v2/persons", {
method: "POST",
body: JSON.stringify({ name, emails: [{ value: email, primary: true, label: "work" }] }),
});
personId = created.data.id;
}
const lead = await call("/v1/leads", {
method: "POST",
body: JSON.stringify({
title: `${fields.company || name}: ${fields.interest ?? "website lead"}`,
person_id: personId,
origin_id: event.data.submission_id,
}),
});
if (fields.message) {
await call("/v1/notes", {
method: "POST",
body: JSON.stringify({ lead_id: lead.data.id, content: String(fields.message) }),
});
}
} catch (error) {
return new Response(String(error), { status: 502 });
}
await env.SEEN.put(event.id, "1", { expirationTtl: 60 * 60 * 24 * 5 });
return new Response(null, { status: 204 });
},
};PIPEDRIVE_DOMAINes el subdominio de tu empresa, elacmedeacme.pipedrive.com. El token está en tus preferencias personales, en la pestaña API.SEENes un namespace de Workers KV vinculado al Worker. El id se guarda solo después de crear el lead, así que un fallo a mitad no deja nada que bloquee el reintento.origin_idguarda el id del envío de Ikiform, para que puedas rastrear un lead hasta el envío guardado.- Los leads creados mediante la API tienen
APIcomo origen en Pipedrive.
Comprueba que funciona
- Elige Send test en el endpoint. El relay responde
204e ignora el eventoendpoint.test. - Envía el formulario con una dirección nueva. En segundos aparece un contacto en HubSpot o una persona y un lead en Pipedrive.
- Vuelve a enviarlo con la misma dirección. HubSpot actualiza el mismo contacto; Pipedrive añade un segundo lead a la persona existente.
- Abre la entrega en Webhooks y elige Retry. No se crea nada nuevo.
- Rompe el token y envía. La entrega muestra el error del CRM en el registro; arregla el token y reinténtala.
Errores comunes
- Un lead por envío, no por persona. Suele ser lo correcto en Pipedrive, donde cada consulta es su propio lead. Si prefieres asociar las repeticiones a un lead abierto, busca primero los leads de la persona.
- 3 endpoints por espacio de trabajo. Un solo relay puede llamar a los dos CRM y al chat, lo que deja endpoints libres para otros formularios.
- El spam nunca llega al CRM. El spam no envía webhook. Cuando marcas un envío como no spam, su webhook sale en ese momento y el lead aparece más tarde.
- Los valores no son de fiar. Los valores de los campos vienen del público. Envíalos como valores JSON y nunca construyas query strings ni fórmulas con ellos sin codificarlos.
Preguntas
No una de un clic. Ikiform envía cada envío como un webhook firmado, y un relay o una herramienta de automatización como Zapier, Make o n8n crea el contacto o el lead mediante la API del CRM.
No. El cuerpo del webhook es fijo para cualquier destino, con los valores del formulario en data.fields. Un relay o un paso de automatización lo convierte en la llamada que espera tu CRM.
Haz que cada paso sea seguro de repetir. En HubSpot, haz upsert por email. En Pipedrive, busca la persona por email exacto antes de crear una, y guarda el webhook-id después de crear el lead para que un reintento lo salte.
Abre el endpoint en Webhooks en el panel. Cada entrega muestra sus intentos con el código de estado de tu relay y los primeros 4 KB de su respuesta, así que devuelve el texto del error del CRM en el cuerpo.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.