# 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.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/lead-capture/leads-to-crm

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](https://ikiform.com/es/docs/webhooks.md) firmado, directamente o a través de una herramienta de automatización como [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) o [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). 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](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) o [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) | 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](https://ikiform.com/es/docs/webhooks.md) cubre el payload y los reintentos, y [tu propio servidor](https://ikiform.com/blog/form-submissions-to-your-own-server.md) 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.

Diagrama: Cómo convierte el relay un envío de Ikiform en un contacto de HubSpot

1. fields.email, en minúsculas, pasa a ser email y la clave del upsert.
2. fields.name, la primera palabra, pasa a ser firstname.
3. fields.name, el resto, pasa a ser lastname.
4. fields.company pasa a ser company.
5. fields.interest pasa a ser ikiform_interest, una propiedad que creas tú.
6. HubSpot asigna el propietario y la etapa del ciclo de vida con sus propias reglas; el relay no los toca.

Todo lo de la izquierda sale del cuerpo fijo del webhook de Ikiform. La fila gris se deja a los workflows de HubSpot en lugar de que la fije el relay.

### Configuración

1. En HubSpot, crea una private app (en algunas cuentas HubSpot ya las clasifica como legacy apps) con el scope `crm.objects.contacts.write` y copia su token de acceso en `HUBSPOT_TOKEN`.
2. Crea una propiedad de contacto con el nombre interno `ikiform_interest`, o quita esa línea. HubSpot rechaza las propiedades que no existen.
3. Despliega la ruta de abajo y añade su URL como endpoint de webhook en Ikiform.

`app/api/hubspot/route.ts`:

```ts
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 `429` de HubSpot se convierte en un `429` con `Retry-After`, que Ikiform respeta antes de volver a intentarlo.
- Cualquier otro fallo, incluido un `207` con errores por registro, se convierte en un `502` con 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 `company` vacío en un segundo formulario sobrescribe uno conocido; quita los valores vacíos de `properties` si eso te importa.

> La referencia de HubSpot ahora también publica rutas de API versionadas por fecha y lista esta ruta `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`.

Diagrama: El relay de Pipedrive, con cada paso seguro de repetir

1. Llega una entrega con su webhook-id.
2. El relay busca ese id en un almacén clave-valor.
3. Si ya se procesó, el relay responde 200 y se detiene.
4. Si no, busca personas por email exacto y crea una solo cuando no hay ninguna coincidencia.
5. Añade un lead vinculado a esa persona, luego una nota con el mensaje, y registra el webhook-id.

La búsqueda de la persona hace que un reintento sea seguro antes de que exista el lead. El webhook-id guardado lo hace seguro después.

`src/index.ts`:

```ts
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_DOMAIN` es el subdominio de tu empresa, el `acme` de `acme.pipedrive.com`. El token está en tus preferencias personales, en la pestaña API.
- `SEEN` es 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_id` guarda 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 `API` como origen en Pipedrive.

## Comprueba que funciona

1. Elige **Send test** en el endpoint. El relay responde `204` e ignora el evento `endpoint.test`.
2. Envía el formulario con una dirección nueva. En segundos aparece un contacto en HubSpot o una persona y un lead en Pipedrive.
3. Vuelve a enviarlo con la misma dirección. HubSpot actualiza el mismo contacto; Pipedrive añade un segundo lead a la persona existente.
4. Abre la entrega en **Webhooks** y elige **Retry**. No se crea nada nuevo.
5. 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

### ¿Tiene Ikiform una integración con HubSpot o Pipedrive?

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.

### ¿Puedo cambiar el cuerpo del webhook para que encaje con la API de mi 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.

### ¿Cómo evito contactos duplicados cuando se reintenta un webhook?

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.

### ¿Dónde veo por qué un lead no llegó al CRM?

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.

## Más guías

- [Formulario de captación de leads](https://ikiform.com/es/guides/lead-capture.md): Un formulario de captación de leads en tu propio sitio que te avisa por email con reply-to, avisa al chat del equipo y alimenta tu CRM, para responder mientras el lead sigue ahí.
- [Formulario de solicitud de demo](https://ikiform.com/es/guides/lead-capture/demo-request.md): Un formulario para reservar una demo que guarda el lead y luego envía al visitante a Cal.com o Calendly con su nombre y email ya rellenados.
- [Formulario de consulta de proyecto](https://ikiform.com/es/guides/lead-capture/project-inquiry.md): Un formulario de inicio de proyecto para estudios y freelances que clasifica las consultas en calientes, templadas y sin encaje por presupuesto y plazos antes de que nadie lea el briefing.
- [Formulario de solicitud de presupuesto](https://ikiform.com/es/guides/lead-capture/quote-request.md): Un formulario de solicitud de presupuesto que recoge los briefs como enlaces compartidos en lugar de archivos, pone fecha límite a cada solicitud y evita que los leads con muchos enlaces acaben en spam.
- [Formulario de solicitud de consulta](https://ikiform.com/es/guides/lead-capture/consultation-request.md): Un formulario de solicitud de consulta para gestorías, abogados, coaches y clínicas que pregunta cómo y cuándo llamar, y deja los detalles sensibles para la llamada.
- [Formulario de leads en Next.js](https://ikiform.com/es/guides/lead-capture/nextjs.md): Un formulario de leads en el App Router de Next.js que valida con Zod en una server action, guarda los UTMs del primer contacto en una cookie y envía los leads cualificados a tu calendario.
