# Envoyer les leads d'un formulaire vers HubSpot ou Pipedrive

> Convertissez chaque webhook Ikiform signé en upsert de contact HubSpot ou en appels person et lead de Pipedrive, sans contacts en double quand une livraison est réessayée.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/lead-capture/leads-to-crm

Faites pointer un webhook Ikiform vers un petit relais qui vérifie la signature et convertit le lead pour l'API de votre CRM : l'upsert par lot de HubSpot avec l'e-mail comme clé, ou la recherche, la création de personne puis la création de lead de Pipedrive. Le corps du webhook d'Ikiform est fixe, c'est donc le relais qui fait la conversion. Rendez chaque étape rejouable sans risque, car une livraison échouée est réessayée avec le même `webhook-id`.

## Ce qui change quand on envoie des leads vers un CRM

Il n'y a pas d'intégrations en un clic. Chaque outil est atteint par un [webhook](https://ikiform.com/fr/docs/webhooks.md) signé, directement ou via un outil d'automatisation comme [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) ou [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). Le corps du webhook est le même pour chaque destination : `data.fields` contient les valeurs du formulaire, et `data.meta` l'origine, le referrer, le pays et l'adresse de réponse. Ikiform ne vous laisse pas le remodeler : quelque chose entre les deux le transforme donc en l'appel qu'attend votre CRM.

Deux choses rendent un CRM plus délicat qu'un canal de chat :

- **Un CRM conserve des fiches, pas des messages.** Publier deux fois le même lead dans Slack, c'est du bruit. Créer deux fois le même contact, c'est un désordre que quelqu'un fusionne à la main. Le relais doit chercher avant de créer.
- **Les nouvelles tentatives font partie du contrat.** Si votre relais ou le CRM échoue, Ikiform réessaie la livraison pendant environ 3 jours avec le même `webhook-id`. Un relais qui a à moitié réussi la première fois ne doit rien dupliquer la deuxième.

## Relais ou outil d'automatisation

| Option | Points forts | Compromis |
| --- | --- | --- |
| [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) ou [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md) | Étapes HubSpot et Pipedrive prêtes à l'emploi, avec des actions de recherche ou création, sans code | Prix à la tâche, et un compte de plus qui détient vos identifiants CRM |
| Votre propre relais | Correspondance exacte des champs, vos propres règles de doublons, rien de facturé par lead | Vous le déployez et le surveillez. Environ 60 lignes par CRM, montrées ci-dessous |

Dans les deux cas, la configuration du webhook est la même : ajoutez l'endpoint sous **Webhooks**, copiez son secret `whsec_` et envoyez un test. La [documentation des webhooks](https://ikiform.com/fr/docs/webhooks.md) détaille le payload et les nouvelles tentatives, et [votre propre serveur](https://ikiform.com/blog/form-submissions-to-your-own-server.md) approfondit la vérification des livraisons.

## HubSpot : upsert du contact par e-mail

HubSpot considère l'e-mail comme l'identifiant unique d'un contact, et son endpoint d'upsert par lot accepte `idProperty: "email"`. Un seul appel crée le contact si l'adresse est nouvelle et le met à jour sinon : une nouvelle tentative ne peut donc pas créer de doublon.

Schéma : Comment le relais convertit une soumission Ikiform en contact HubSpot

1. fields.email, en minuscules, devient email, la clé de l'upsert.
2. fields.name, premier mot, devient firstname.
3. fields.name, le reste, devient lastname.
4. fields.company devient company.
5. fields.interest devient ikiform_interest, une propriété que vous créez.
6. HubSpot définit le propriétaire et l'étape du cycle de vie selon ses propres règles ; le relais n'y touche pas.

Tout ce qui est à gauche vient du corps fixe du webhook d'Ikiform. La ligne grise est laissée aux workflows HubSpot plutôt que définie par le relais.

### Configuration

1. Dans HubSpot, créez une app privée (HubSpot les classe désormais comme legacy apps sur certains comptes) avec le scope `crm.objects.contacts.write`, et copiez son token d'accès dans `HUBSPOT_TOKEN`.
2. Créez une propriété de contact avec le nom interne `ikiform_interest`, ou supprimez cette ligne. HubSpot refuse les propriétés qui n'existent pas.
3. Déployez la route ci-dessous et ajoutez son URL comme endpoint de webhook dans 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 devient un `429` avec `Retry-After`, qu'Ikiform respecte avant de réessayer.
- Tout autre échec, y compris un `207` avec des erreurs par fiche, devient un `502` avec la réponse de HubSpot comme corps. Les 4 premiers Ko arrivent dans le journal de livraison d'Ikiform : vous pouvez donc lire le message de HubSpot sans vos propres logs.
- L'upsert envoie toutes les propriétés à chaque fois, car HubSpot ne prend pas en charge les upserts partiels avec l'e-mail comme clé. Un `company` vide dans un deuxième formulaire écrase une valeur connue ; retirez les valeurs vides de `properties` si cela compte pour vous.

> La référence de HubSpot publie désormais aussi des chemins d'API versionnés par date et classe ce chemin `v3` dans sa section legacy. Consultez la référence actuelle des contacts au moment de construire, et gardez le même corps de requête.

## Pipedrive : trouver la personne, puis ajouter un lead

Pipedrive sépare les personnes des leads. Un lead doit être lié à une personne ou à une organisation : le relais trouve ou crée donc la personne, puis ajoute un lead avec un titre. Les personnes et la recherche de personnes utilisent l'API v2 ; les leads et les notes sont des endpoints v1. Les deux prennent votre token d'API dans l'en-tête `x-api-token`.

Schéma : Le relais Pipedrive, avec chaque étape rejouable sans risque

1. Une livraison arrive avec son webhook-id.
2. Le relais cherche cet id dans un stockage clé-valeur.
3. S'il a déjà été traité, le relais répond 200 et s'arrête.
4. Sinon, il cherche les personnes par e-mail exact et n'en crée une que si aucune ne correspond.
5. Il ajoute un lead lié à cette personne, puis une note avec le message, et enregistre le webhook-id.

La recherche de personne rend une nouvelle tentative sûre avant que le lead n'existe. Le webhook-id stocké la rend sûre aprè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` est le sous-domaine de votre entreprise, le `acme` de `acme.pipedrive.com`. Le token se trouve dans vos préférences personnelles, onglet API.
- `SEEN` est un namespace Workers KV lié au Worker. L'id n'est stocké qu'après la création du lead : un échec en cours de route ne laisse donc rien qui bloquerait la nouvelle tentative.
- `origin_id` contient l'id de soumission Ikiform, pour remonter d'un lead à la soumission stockée.
- Les leads créés via l'API ont `API` comme source dans Pipedrive.

## Vérifiez que tout fonctionne

1. Choisissez **Send test** sur l'endpoint. Le relais répond `204` et ignore l'événement `endpoint.test`.
2. Envoyez le formulaire avec une nouvelle adresse. Un contact HubSpot ou une personne et un lead Pipedrive apparaissent en quelques secondes.
3. Envoyez à nouveau avec la même adresse. HubSpot met à jour l'unique contact ; Pipedrive ajoute un second lead à la personne existante.
4. Ouvrez la livraison sous **Webhooks** et choisissez **Retry**. Rien de nouveau n'est créé.
5. Cassez le token et envoyez. La livraison affiche l'erreur du CRM dans le journal ; corrigez le token et réessayez-la.

## Pièges

- **Un lead par soumission, pas par personne.** C'est généralement ce qu'il faut dans Pipedrive, où chaque demande est un lead à part. Si vous préférez rattacher les répétitions à un lead ouvert, cherchez d'abord les leads de la personne.
- **3 endpoints par espace de travail.** Un seul relais peut appeler les deux CRM et le chat, ce qui laisse des endpoints libres pour d'autres formulaires.
- **Le spam n'atteint jamais le CRM.** Le spam n'envoie aucun webhook. Quand vous marquez une soumission comme non-spam, son webhook part à ce moment-là, et le lead apparaît en retard.
- **Les valeurs ne sont pas fiables.** Les valeurs des champs viennent du public. Envoyez-les comme valeurs JSON, et ne construisez jamais de paramètres d'URL ni de formules à partir d'elles sans les encoder.

## Questions

### Ikiform a-t-il une intégration HubSpot ou Pipedrive ?

Pas en un clic. Ikiform envoie chaque soumission sous forme de webhook signé, et un relais ou un outil d'automatisation comme Zapier, Make ou n8n crée le contact ou le lead via l'API du CRM.

### Puis-je modifier le corps du webhook pour qu'il corresponde à l'API de mon CRM ?

Non. Le corps du webhook est fixe pour chaque destination, avec les valeurs du formulaire sous data.fields. Un relais ou une étape d'automatisation le convertit en l'appel qu'attend votre CRM.

### Comment éviter les contacts en double quand un webhook est réessayé ?

Rendez chaque étape rejouable sans risque. Dans HubSpot, faites un upsert par e-mail. Dans Pipedrive, cherchez la personne par e-mail exact avant d'en créer une, et stockez le webhook-id après avoir créé le lead pour qu'une nouvelle tentative le saute.

### Où voir pourquoi un lead n'a pas atteint le CRM ?

Ouvrez l'endpoint sous Webhooks dans le tableau de bord. Chaque livraison liste ses tentatives avec le code de statut de votre relais et les 4 premiers Ko de sa réponse : renvoyez donc le texte d'erreur du CRM dans le corps.

## Autres guides

- [Formulaire de capture de leads](https://ikiform.com/fr/guides/lead-capture.md): Un formulaire de capture de leads sur votre propre site, qui vous écrit avec le lead en adresse de réponse, alerte le chat de l'équipe et alimente votre CRM, pour répondre tant que le lead est encore là.
- [Formulaire de demande de démo](https://ikiform.com/fr/guides/lead-capture/demo-request.md): Un formulaire de réservation de démo qui stocke le lead, puis envoie le visiteur vers Cal.com ou Calendly avec son nom et son e-mail déjà remplis.
- [Formulaire de demande de projet](https://ikiform.com/fr/guides/lead-capture/project-inquiry.md): Un formulaire de démarrage de projet pour studios et freelances, qui trie les demandes en chaudes, tièdes et hors cible selon le budget et le calendrier, avant que quiconque ne lise le brief.
- [Formulaire de demande de devis](https://ikiform.com/fr/guides/lead-capture/quote-request.md): Un formulaire de demande de devis qui reçoit les briefs sous forme de liens partagés plutôt qu'en pièces jointes, fixe une échéance à chaque demande et garde les leads riches en liens hors du spam.
- [Formulaire de demande de consultation](https://ikiform.com/fr/guides/lead-capture/consultation-request.md): Un formulaire de demande de consultation pour comptables, avocats, coachs et cabinets, qui demande comment et quand appeler, et garde les détails sensibles pour l'appel.
- [Formulaire de leads Next.js](https://ikiform.com/fr/guides/lead-capture/nextjs.md): Un formulaire de leads Next.js App Router qui valide avec Zod dans une server action, garde les UTM du premier contact dans un cookie et envoie les leads qualifiés vers votre agenda.
