Aller au contenu

Automatically Enriching Lemlist Contacts with Apollo via n8n

Two sales reps, two different ways of dumping a half-empty contact into our outreach CRM. Here's how we ended up building two n8n workflows to fill that gap, and more importantly, everything that broke along the way: an endpoint that returns an empty CSV instead of JSON, an editing bug that silently duplicated parameters, a rate limit we hit twice, and a "job title in English" that turned out not to be a bug at all.


The problem: two ways of arriving incomplete in Lemlist

The starting point isn't an architecture problem, it's a field problem. Two situations keep coming up:

Case 1. A sales rep adds a prospect to a campaign via the Lemlist Chrome extension, straight from a LinkedIn profile. Result: we get a first name, a last name, a LinkedIn URL... and nothing else. No email, no phone, no job title, no company filled in.

Case 2. A campaign is running, and someone replies in the Lemlist Inbox: "you need to contact so-and-so@theircompany.org, they handle that". We end up with a raw email address, picked up from a reply, and nothing more: no name, no job title, nothing to personalize with.

In both cases, the data we have is too thin to prospect properly, and Apollo is already connected to fill exactly this kind of gap. The goal: two n8n automations, one running as a background job across the whole database to catch case 1, one triggered on demand for case 2.


First attempt: scanning campaigns (and why it doesn't work)

The obvious approach: scan Lemlist campaigns, list their leads, spot the ones with empty fields, pass them to Apollo, write the result back. Except every step of that plan broke.

An endpoint that returns CSV when you expect JSON

First instinct: a call to GET /campaigns/:id/export/leads. The response looked like this:

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

Just the CSV header row, no data rows at all. This isn't a synchronous listing endpoint: it's the trigger for an asynchronous export (the "we'll prepare a file, you download it later" kind), not a JSON response you can actually use in a workflow.

The real endpoints exist, but return thin data

The official documentation reveals two distinct endpoints:

  • GET /campaigns/{campaignId}/leads/: lists a campaign's leads, but only returns thin references: { _id, contactId, state }. No email, no name.
  • GET /leads?id={leadId}: this gets you the full lead (email, firstName, lastName, jobTitle, companyDomain, linkedinUrl...), but one at a time.

It works, but that means at least two API calls per lead before you even touch Apollo. With a rate limit of 20 requests per 2 seconds per API key (documented, and we did actually hit it, see below), looping through dozens of campaigns quickly becomes a scale problem rather than a logic problem.

The real deathblow: orphaned contacts

Contacts nowhere to be found in the campaigns

Searching for the three case-1 contacts across the account's 32 campaigns turned up nothing. Yet their record clearly existed, with campaignCount: 1. The explanation: their original campaign had since been deleted, but Lemlist keeps the Contact record independently of its campaigns (a Contact can exist without being attached to any active campaign). Scanning "by campaign" would therefore never have found them, even if every endpoint above had worked perfectly on the first try.


The real source of truth: GET/POST /contacts

Lemlist distinguishes between two objects: the Lead (an instance of a contact within a specific campaign) and the Contact (the central, unique record that exists independently of campaigns). The right playing field for a catch-up pipeline isn't leads, it's the Contacts database directly.

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 } }

Two points that needed real-world verification rather than just reading the docs:

  • The separately documented endpoint for updating "standard" fields (PATCH /campaigns/:id/leads/:id) only accepts firstName, lastName, companyName, jobTitle, preferredContactMethod; no email, no phone. The POST /contacts upsert is far more permissive and covers everything in a single call.
  • Custom fields must already be registered in the team's field repository to be accepted by the API. There's no way to create a new field on the fly via the API. That has a direct consequence on the architecture, detailed right after this.

Architecture of the automated workflow

The workflow runs every 15 minutes, during business hours (cron */15 8-19 * * 1-5):

flowchart TD
    A["Paginated fetch\n/contacts (500 per page)"] --> B["Flatten\npages into items"]
    B --> C["Incompleteness filter\nmissing email, phone, title or company"]
    C --> D["Already-processed filter\nn8n Data Table"]
    D --> E["Apollo enrichment\nperson.enrich"]
    E --> F["Lemlist upsert\nPOST /contacts"]
    F --> G["Logging\nprocessed contact"]

The most counter-intuitive part of this architecture is the "already processed" filtering step. The natural temptation is to mark a contact as "already attempted" by writing a Lemlist custom field like apolloEnrichedAt. Except that field doesn't exist in the team's field repository, and the API refuses to create it on the fly (see above). The fix: move that state entirely out of Lemlist, and store it in a dedicated n8n Data Table (contactId, processedAt, outcome), queried via the Data Table node's native rowNotExists operation. That also avoids replaying Apollo indefinitely on contacts whose enrichment already failed once.


The big run: 1,059 contacts, 168 incomplete, two rate limits hit

Before unleashing the workflow on the whole database, we first ran the pipeline in read-only mode (no Apollo, no writes) to measure the real scale: 1,059 contacts in total, 168 incomplete ones never processed. Enough to cool any urge to just run it blind: 168 potential Apollo credits and 168 real writes in a single execution, without having checked the result on a single concrete case.

First test: three named contacts, filtered explicitly (the ones from case 1 in the introduction). Concrete result:

Contact Before After Apollo enrichment
Contact A LinkedIn only Email found, job title found, city/region/country
Contact B LinkedIn only Job title found, email not found (Apollo: "unavailable")
Contact C LinkedIn only Email found, job title found, city/region/country

On the remaining 165, two runs stopped dead with the same error:

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

Rate limit hit twice

The Lemlist rate limit (20 requests / 2 seconds) was biting on upsert calls fired back-to-back with no delay. The fix needed three cumulative adjustments, not just one: spacing between calls (batchInterval), an automatic retry policy on the HTTP node (5 attempts, 5-second wait), and onError: continueRegularOutput so an isolated failure no longer interrupted the entire execution. Once all three were in place, the full run went through without a single error.


The on-demand form: two steps, no dedicated backend

For case 2 (an email picked up from an Inbox reply), we want a simple form a sales rep can fill in on the fly: name, email if known, LinkedIn if known, company, and above all the target campaign to create the lead in.

The first instinct (a static dropdown of campaigns hardcoded into the form definition) had an obvious problem: it freezes at build time and goes stale the moment a campaign is created or renamed. Two options were on the table:

  • A daily cron n8n sub-workflow that rewrites the list via n8n's own management API (which means creating a dedicated n8n API key);
  • Making the form itself dynamic.

The second option won: n8n lets you define a multi-page form, where a page can receive its field list dynamically generated as JSON by a Code node, executed at the moment the page is displayed. Concretely:

// Step 1: static form (name, email, LinkedIn, company)
// Step 2: Code node that queries Lemlist live
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 })) }
    }]
  }
}];
// → passed as the "jsonOutput" parameter of the Form node (page 2)

Result: the campaign list is always up to date, with no n8n API key, no refresh sub-workflow, no state to sync.

The trap of the "lone" required field

First draft of the form: first name and last name marked as required. Except the real use case (an email alone, picked up from a reply) provides neither. The real constraint isn't "first and last name required", it's "email OR LinkedIn OR (first name AND last name)", what Apollo needs to receive in order to look anything up. A single required field can't express that cross-logic; it had to move into the code (a simple hasEnoughInfo = !!(email || linkedinUrl || (firstName && lastName))) and every field became individually optional, with an explicit message at the top of the form so the user doesn't get lost.


Post-mortem: the bugs we took a while to notice

The ghost-parameter bug

A fix that was announced but never actually applied

The trickiest bug of this session wasn't in the workflow itself, but in the tooling used to edit it via API. A partial edit of a nested parameter (changing just formFields on a node) silently created a duplicated "parameters" key nested inside the existing parameters, without ever touching the value that was actually live. The result: we'd announce a fix ("first/last name are no longer required"), it would show up fine when re-reading the configuration... and the form still displayed the red asterisks in real life, because the node was still reading the old value at the root level. The fix: never do a partial edit on these parameters again, always replace the entire object.

The general lesson: when an "announced" fix doesn't show up in actual behavior, don't assume the fix is correct and the render is just cached: re-read the actually-active configuration before concluding anything. This same n8n node-editing trap showed up again, in a different form, on another project: see Automating a Grammar Variable with AI for the details of the exact JSON path that caused it.

The "English job title" that wasn't a bug

Two contacts out of three came back from Apollo with an English job title, despite being at French companies. Initial reflex: Apollo must be normalizing and translating. Digging into the raw data (employment_history), the title field matched the current job title exactly, word for word: no normalization, no translation. These two people had simply written their job title in English on LinkedIn (a common practice in some marketing/data departments, even in France). So there's nothing to fix on the code side; the only real option would be adding an AI translation step, a decision we deliberately left aside to preserve fidelity to the source.

The fields you forget to carry over

Two separate oversights, found at two different points while reviewing the actual output:

  • companyName was never extracted from the Apollo response (only companyDomain was), fixed by adding apollo.organization.name as a fallback.
  • firstName/lastName were never carried over from Apollo in the on-demand form: the code only forwarded what the sales rep had typed in, empty if they'd only entered an email. A contact created with just an email therefore ended up with no name in Lemlist, despite Apollo having found one. Same fix as the companyName case: firstName: req.firstName || apollo.first_name || ''.

The missing "sender" wasn't a bug either

A lead created via the API arrives in Lemlist with the status "To launch" and no sender assigned, while other leads in the same campaign have one. This isn't specific to the API: it's the normal behavior of Lemlist's Launch step. A lead that's added (via extension, CSV, or API) never automatically enters the sequence: it waits for a manual review (data quality, personalization) before being "launched". The sender is only assigned at that precise moment, not when the lead is created.


Results

  • 168 contacts enriched in a single run once the pipeline's reliability was validated (rate limit handled, fields complete).
  • An on-demand form a sales rep can complete in under 30 seconds, without ever needing to know a campaign's ID.
  • No extra n8n API key to manage, no sync sub-workflow to maintain.
  • A tracking Data Table that makes the whole thing idempotent: replaying the workflow never reprocesses a contact that's already been through Apollo.

Conclusion

The longest part of this project wasn't writing the enrichment logic: that's three nodes and one Apollo call. The real work was understanding where the data actually lives in Lemlist (Contact rather than Lead), which endpoints truly return usable JSON rather than an export trigger, and verifying every assumption on a real sample before unleashing a bulk run. How often "looks fixed" and "actually works" diverged during this session (the CSV endpoint, the duplicated-parameters bug, the fields that never got carried over) is a good reminder that in an automation touching third-party systems, the only verification that counts is a real test on a concrete case, not a code review.


To go further: The SDR Lead Machine and AI Detection of Business Signals, two other RevOps pipelines built on the same AI-driven enrichment logic.