Aller au contenu

Enrichir automatiquement des contacts Lemlist avec Apollo via n8n

Deux commerciaux, deux façons différentes de faire arriver un contact à moitié vide dans notre CRM d'outreach. Voici comment on a fini par construire deux workflows n8n pour combler ce trou, et surtout tout ce qui a cassé en chemin : un endpoint qui renvoie du CSV vide au lieu de JSON, un bug d'édition qui dupliquait silencieusement des paramètres, un rate limit qu'on a mangé deux fois, et un "poste en anglais" qui n'était en fait pas un bug du tout.


Le problème : deux façons d'arriver incomplet dans Lemlist

Le point de départ n'est pas un problème d'architecture, c'est un problème de terrain. Deux situations reviennent sans cesse :

Cas 1. Un commercial ajoute un prospect à une campagne via l'extension Chrome Lemlist, directement depuis un profil LinkedIn. Résultat : on a un nom, un prénom, une URL LinkedIn... et rien d'autre. Pas d'email, pas de téléphone, pas de poste, pas de société renseignée.

Cas 2. Une campagne tourne, quelqu'un répond dans l'Inbox Lemlist : "il faut contacter untel@sasociete.org, c'est lui qui gère ça". On se retrouve avec un email brut, glané dans une réponse, et rien de plus : pas de nom, pas de poste, rien à personnaliser.

Dans les deux cas, la donnée qu'on a est trop pauvre pour prospecter correctement, et Apollo est déjà connecté pour combler ce genre de trou. Le but : deux automatisations n8n, une qui tourne en tâche de fond sur toute la base pour rattraper le cas 1, une déclenchée à la demande pour le cas 2.


Première tentative : scanner les campagnes (et pourquoi ça ne marche pas)

L'approche évidente : scanner les campagnes Lemlist, lister leurs leads, repérer ceux avec des champs vides, les passer à Apollo, réécrire le résultat. Sauf que chaque étape de ce plan a cassé.

Un endpoint qui renvoie du CSV alors qu'on attend du JSON

Premier réflexe : un appel GET /campaigns/:id/export/leads. La réponse ressemblait à ça :

{
  "data": "emailStatus,email,firstName,lastName,picture,phone,linkedinUrl,timezone,jobTitle,..."
}

Juste la ligne d'en-têtes CSV, aucune ligne de donnée. Ce n'est pas un endpoint de listing synchrone : c'est le déclencheur d'un export asynchrone (le genre "on te prépare un fichier, tu le télécharges après"), pas une réponse JSON exploitable dans un workflow.

Les vrais endpoints existent, mais renvoient des données minces

La documentation officielle révèle deux endpoints distincts :

  • GET /campaigns/{campaignId}/leads/ : liste les leads d'une campagne, mais ne renvoie que des références minces : { _id, contactId, state }. Aucun email, aucun nom.
  • GET /leads?id={leadId} : là on récupère le lead complet (email, firstName, lastName, jobTitle, companyDomain, linkedinUrl...), mais un par un.

Ça marche, mais ça veut dire deux appels API par lead au minimum, avant même de toucher à Apollo. Avec un rate limit de 20 requêtes par 2 secondes par clé API (documenté, et on l'a effectivement mangé, voir plus bas), scanner des dizaines de campagnes en boucle devient vite un problème d'échelle plutôt qu'un problème de logique.

Le vrai coup de grâce : les contacts orphelins

Des contacts introuvables dans les campagnes

En cherchant les trois contacts du cas 1 dans les 32 campagnes du compte, aucune trace. Pourtant leur fiche existait bien, avec campaignCount: 1. L'explication : leur campagne d'origine avait été supprimée depuis, mais Lemlist conserve la fiche Contact indépendamment de ses campagnes (un Contact peut exister sans être rattaché à aucune campagne active). Scanner "par campagne" ne les aurait donc jamais retrouvés, même si tous les endpoints ci-dessus avaient fonctionné du premier coup.


La vraie source de vérité : GET/POST /contacts

Lemlist distingue deux objets : le Lead (une instance d'un contact dans une campagne précise) et le Contact (la fiche centrale, unique, qui existe indépendamment des campagnes). Le bon terrain de jeu pour une base de rattrapage, ce n'est pas les leads, c'est directement la base Contacts.

GET /contacts?limit=500&offset=0
→ { data: [...], total: 1059, limit: 500, offset: 0 }

POST /contacts
{
  "contactId": "ctc_xxx",
  "email": "...",
  "jobTitle": "...",
  "companyName": "...",
  "apolloSeniority": "...",
  ...
}
→ { success: true, data: { updated: true } }

Deux points qui ont demandé une vérification en réel plutôt qu'une lecture de doc :

  • Le endpoint de mise à jour des champs "standards" documenté séparément (PATCH /campaigns/:id/leads/:id) n'accepte que firstName, lastName, companyName, jobTitle, preferredContactMethod ; ni email, ni téléphone. L'upsert POST /contacts est bien plus permissif et couvre tout en un seul appel.
  • Les champs personnalisés (custom fields) doivent être déjà enregistrés dans le référentiel de l'équipe pour être acceptés par l'API. Impossible de créer un nouveau champ à la volée par API. Ça a une conséquence directe sur l'architecture, détaillée juste après.

Architecture du workflow automatique

Le workflow tourne toutes les 15 minutes, en heures ouvrées (cron */15 8-19 * * 1-5) :

flowchart TD
    A["Recuperation paginee\n/contacts (500 par page)"] --> B["Aplatissement\ndes pages en items"]
    B --> C["Filtre incompletude\nemail, tel, poste ou societe manquant"]
    C --> D["Filtre deja traite\nData Table n8n"]
    D --> E["Enrichissement Apollo\nperson.enrich"]
    E --> F["Upsert Lemlist\nPOST /contacts"]
    F --> G["Journalisation\ncontact traite"]

Le point le plus contre-intuitif de cette architecture, c'est l'étape de filtrage "déjà traité". La tentation naturelle est de marquer un contact "déjà tenté" en écrivant un champ personnalisé Lemlist du type apolloEnrichedAt. Sauf que ce champ n'existe pas dans le référentiel de l'équipe, et l'API refuse de le créer à la volée (voir plus haut). Solution : sortir complètement cet état de Lemlist, et le stocker dans une Data Table n8n dédiée (contactId, processedAt, outcome), interrogée via l'opération native rowNotExists du nœud Data Table. Ça évite aussi de rejouer Apollo indéfiniment sur des contacts pour lesquels l'enrichissement a échoué une première fois.


Le grand run : 1059 contacts, 168 incomplets, deux rate limits mangés

Avant de lâcher le workflow sur toute la base, on a d'abord fait tourner le pipeline de lecture seule (sans Apollo, sans écriture) pour mesurer l'ampleur réelle : 1059 contacts au total, 168 incomplets jamais traités. De quoi refroidir l'envie de lancer ça en aveugle : 168 crédits Apollo potentiels et 168 écritures réelles en une seule exécution, sans avoir vérifié le résultat sur un seul cas concret.

Premier test : trois contacts nommés, filtrés explicitement (ceux du cas 1 de l'introduction). Résultat concret :

Contact Avant Après enrichissement Apollo
Contact A LinkedIn seul Email trouvé, poste trouvé, ville/région/pays
Contact B LinkedIn seul Poste trouvé, email non trouvé (Apollo : "unavailable")
Contact C LinkedIn seul Email trouvé, poste trouvé, ville/région/pays

Sur les 165 restants, deux exécutions se sont arrêtées net avec la même erreur :

NodeApiError: The service is receiving too many requests from you
httpCode: 429

Rate limit mangé deux fois

Le rate limit Lemlist (20 requêtes / 2 secondes) mordait sur les appels d'upsert enchaînés sans délai. La correction a demandé trois ajustements cumulés, pas un seul : un espacement entre appels (batchInterval), une politique de retry automatique sur le nœud HTTP (5 tentatives, 5 secondes d'attente), et onError: continueRegularOutput pour qu'un échec isolé n'interrompe plus l'exécution entière. Une fois les trois en place, le run complet est passé sans erreur.


Le formulaire à la demande : deux étapes, sans backend dédié

Pour le cas 2 (un email glané dans une réponse Inbox), on veut un formulaire simple qu'un commercial remplit à la volée : nom, email si connu, LinkedIn si connu, société, et surtout la campagne cible dans laquelle créer le lead.

Le premier réflexe (une liste déroulante statique de campagnes, écrite en dur dans la définition du formulaire) posait un problème évident : elle se fige au moment de la construction et devient fausse dès qu'une campagne est créée ou renommée. Deux options se présentaient :

  • Un sous-workflow n8n en cron quotidien qui réécrit la liste via l'API de gestion de n8n elle-même (ce qui suppose de créer une clé API n8n dédiée) ;
  • Rendre le formulaire lui-même dynamique.

La seconde option a gagné : n8n permet de définir un formulaire en plusieurs pages, où une page peut recevoir sa liste de champs générée dynamiquement en JSON par un nœud Code, exécuté au moment où la page s'affiche. Concrètement :

// Étape 1 : formulaire statique (nom, email, LinkedIn, société)
// Étape 2 : nœud Code qui interroge Lemlist en direct
const campaigns = $input.all().map(i => i.json)
  .map(c => c.name.trim())
  .sort((a, b) => a.localeCompare(b));

return [{
  json: {
    fields: [{
      fieldName: 'targetCampaignName',
      fieldType: 'dropdown',
      fieldOptions: { values: campaigns.map(name => ({ option: name })) }
    }]
  }
}];
// → passé en paramètre "jsonOutput" du nœud Form (page 2)

Résultat : la liste de campagnes est toujours à jour, sans clé API n8n, sans sous-workflow de rafraîchissement, sans état à synchroniser.

Le piège du champ obligatoire "seul"

Premier jet du formulaire : prénom et nom marqués obligatoires. Sauf que le cas d'usage réel (email seul, glané dans une réponse) ne fournit ni prénom ni nom. La vraie contrainte n'est pas "prénom et nom obligatoires", c'est "email OU LinkedIn OU (prénom ET nom)", ce qu'Apollo a besoin de recevoir pour chercher quelque chose. Un champ requis isolé ne peut pas exprimer cette logique croisée ; il a fallu la déplacer dans le code (un simple hasEnoughInfo = !!(email || linkedinUrl || (firstName && lastName))) et rendre tous les champs individuellement optionnels, avec un message explicite en haut du formulaire pour ne pas perdre l'utilisateur.


Post-mortem : les bugs qu'on a mis du temps à voir

Le bug de paramètres fantômes

Une correction annoncée qui ne s'appliquait jamais

Le plus retors des bugs de cette session n'était pas dans le workflow, mais dans l'outillage utilisé pour l'éditer par API. Une édition partielle d'un paramètre imbriqué (changer juste formFields sur un nœud) créait silencieusement une clé "parameters" dupliquée à l'intérieur des paramètres existants, sans jamais toucher la valeur réellement active. Résultat : on annonçait une correction ("prénom/nom ne sont plus obligatoires"), elle apparaissait bien dans la relecture de la configuration... et le formulaire affichait toujours les astérisques rouges en réel, parce que le nœud lisait toujours l'ancienne valeur au niveau racine. Le correctif : ne plus jamais faire d'édition partielle sur ces paramètres, toujours remplacer l'objet complet.

La leçon générale : quand un correctif "annoncé" ne se voit pas dans le comportement réel, ne pas supposer que le correctif est bon et que le rendu est en cache : relire la configuration réellement active avant de conclure quoi que ce soit. Ce même piège d'édition de nœud n8n s'est reproduit, sous une forme différente, sur un autre chantier : voir Automatiser une variable grammaticale avec une IA pour le détail du chemin JSON qui en était la cause exacte.

Le poste "en anglais" qui n'était pas un bug

Deux contacts sur trois sont revenus d'Apollo avec un intitulé de poste en anglais, malgré des entreprises françaises. Réflexe initial : Apollo normalise et traduit. En creusant la donnée brute (employment_history), le champ title correspondait exactement au titre du poste courant, mot pour mot : aucune normalisation, aucune traduction. Ces deux personnes ont simplement écrit leur intitulé de poste en anglais sur LinkedIn (pratique courante dans certaines directions marketing/data, même en France). Il n'y a donc rien à corriger côté code ; la seule option réelle serait d'ajouter une étape de traduction IA, décision volontairement laissée de côté pour préserver la fidélité à la source.

Les champs qu'on oublie de reprendre

Deux oublis distincts, trouvés à deux moments différents en relisant le résultat réel :

  • companyName n'était jamais extrait de la réponse Apollo (seul companyDomain l'était), corrigé en ajoutant apollo.organization.name en repli.
  • firstName/lastName n'étaient jamais repris depuis Apollo dans le formulaire à la demande : le code ne faisait que retransmettre ce que le commercial avait saisi, vide s'il n'avait tapé qu'un email. Un contact créé avec seulement un email s'est donc retrouvé sans nom dans Lemlist, malgré un nom trouvé par Apollo. Correction identique au cas companyName : firstName: req.firstName || apollo.first_name || ''.

Le "sender" manquant n'était pas un bug non plus

Un lead créé par API arrive dans Lemlist avec le statut "To launch" et sans expéditeur (sender) assigné, alors que les autres leads de la même campagne en ont un. Ce n'est pas spécifique à l'API : c'est le comportement normal de l'étape de Launch de Lemlist. Un lead ajouté (extension, CSV ou API) n'entre jamais automatiquement dans la séquence : il attend une revue manuelle (qualité des données, personnalisation) avant d'être "launché". L'expéditeur n'est assigné qu'à ce moment précis, pas à la création du lead.


Résultats

  • 168 contacts enrichis en un seul run une fois la fiabilité de la pipeline validée (rate limit géré, champs complets).
  • Un formulaire à la demande opérationnel en moins de 30 secondes pour un commercial, sans qu'il ait besoin de connaître l'ID d'une campagne.
  • Aucune clé API n8n supplémentaire à gérer, aucun sous-workflow de synchronisation à maintenir.
  • Une Data Table de suivi qui rend l'ensemble idempotent : rejouer le workflow ne re-traite jamais un contact déjà passé par Apollo.

Conclusion

La partie la plus longue de ce chantier n'a pas été d'écrire la logique d'enrichissement : ça, c'est trois nœuds et un appel Apollo. Le vrai travail a été de comprendre où se trouve réellement la donnée dans Lemlist (Contact plutôt que Lead), quels endpoints renvoient vraiment du JSON exploitable plutôt qu'un déclencheur d'export, et de vérifier chaque hypothèse sur un échantillon réel avant de lâcher un traitement en masse. Le nombre de fois où "ça a l'air corrigé" et "ça marche en réel" ont divergé pendant cette session (endpoint CSV, bug de paramètres dupliqués, champs jamais repris) est un bon rappel que dans une automatisation qui touche à des systèmes tiers, la seule vérification qui compte est un test réel sur un cas concret, pas une relecture de code.


Pour aller plus loin : Construire une machine à leads SDR et Détection IA de signaux d'affaires, deux autres pipelines RevOps construits sur la même logique d'enrichissement piloté par IA.