4 min

n8n

Automatically Enriching Lemlist Contacts with Apollo via n8n

Contacts were landing half-empty in Lemlist, our outreach tool. I built two n8n workflows that fill them in with Apollo: one running in the background over the whole base, one on-demand form for the sales reps. The longest part was everything that broke around it.

The problem

Two cases keep coming up:

  • Case 1. A rep adds a prospect from LinkedIn with the Lemlist Chrome extension. We get a name and a LinkedIn URL, nothing else: no email, no phone, no job title, no company.
  • Case 2. Someone replies in the Lemlist Inbox: “you should contact so-and-so@theircompany.org”. We get a raw email, with no name or job title.

Apollo was already connected to fill this kind of gap.

Scanning campaigns: a dead end

The obvious approach (list campaign leads, spot empty fields, send them to Apollo) broke at every step:

  • GET /campaigns/:id/export/leads returns only the CSV header row: it triggers an asynchronous export, it is not a JSON listing.
  • Thereal endpoints exist, but GET /campaigns/{campaignId}/leads/ only returns { _id, contactId, state }. You then need one GET /leads?id= per lead. With a rate limit of 20 requests per 2 seconds, that doesn’t scale.
  • Above all, the three contacts from case 1 were nowhere to be found in the account’s 32 campaigns. Their original campaign had been deleted, but Lemlist keeps the Contact record independently. A per-campaign scan would never have found them.

What I built

Lemlist distinguishes the Lead (a contact inside one campaign) from the Contact (the central, unique record). The right source is the Contacts base: paginated GET /contacts (500 per page) to read, POST /contacts as an upsert to write. This upsert accepts email and phone, unlike the leads PATCH, which is limited to a few standard fields.

The automatic workflow runs every 15 minutes during business hours:

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

A contact can’t be marked “already processed” with a Lemlist custom field: the API rejects fields that aren’t declared in the team’s field registry. So the state lives in an n8n Data Table (contactId, processedAt, outcome). The workflow becomes idempotent and never replays Apollo on a failure.

For case 2, a two-page n8n form: the first page for name, email, LinkedIn, company; the second shows the list of campaigns, generated as JSON by a Code node when the page is displayed. The list is always up to date, with no frozen list and no sync sub-workflow.

No field is individually required. The real rule is “email OR LinkedIn OR (first name AND last name)”, which a required field can’t express: it is checked in code.

The big run

Before writing anything, I ran the read-only part: 1059 contacts, of which 168 incomplete. First test on the three contacts from case 1: two got email, job title and location, the third only the job title (email “unavailable” in Apollo).

On the rest, two executions stopped on a 429 error.

Rate limit hit twice

It took three combined settings: spacing between calls (batchInterval), a retry on the HTTP node (5 attempts, 5 seconds), and onError: continueRegularOutput so a single failure doesn’t stop everything. After that, all 168 contacts went through.

What broke

An announced fix that never applied

When editing the form through the API, a partial update of a nested parameter created a duplicated parameters key inside the existing parameters. Reading the config back showed the fix, but the node still read the old value. Fix: always replace the whole object. The same trap came back on the grammar variable.

  • Fields never carried over. companyName wasn’t extracted from Apollo (fallback added on apollo.organization.name). And the form didn’t carry over the first and last name found by Apollo: a contact created from an email alone stayed nameless.
  • The job title in English. Apollo returns the exact title from the LinkedIn profile, untranslated. These people had written it in English. Nothing to fix: I’d rather stay faithful to the source.
  • The missing sender. A lead created through the API arrives as “To launch”, with no sender. That’s normal Lemlist behavior: every lead waits for a manual review before being launched, and the sender is assigned at that point.

Takeaways

The enrichment logic fits in three nodes. The real work was understanding where the data lives (Contact rather than Lead) and checking every assumption on a real case before running in bulk. When “it looks fixed”, I re-read the actually-active configuration before concluding.

To go further: The SDR Lead Machine and AI Detection of Business Signals.

Read next

59Apollo Counts Everything: I Only Show It the Pages That MatterTeamsDaily Lemlist Task Reminders in 1:1 Teams Chats with n8nLinkedInGenerating LinkedIn Posts with AI for Sales Reps Covering Multiple Industries