How to Enrich and Deduplicate New CRM Contacts with AI Before Import
Build a Spojit workflow that takes a raw contact list, uses an Agent-mode Connector node to standardize names and infer company and segment as structured JSON, filters out contacts already in Klaviyo, enriches the survivors with an HTTP lookup, and creates clean profiles.
What This Integration Does
Raw contact lists are messy. People type their names in inconsistent casing, leave the company field blank, and the same person turns up two or three times across spreadsheets. Importing that straight into your marketing platform pollutes your audience, skews your segments, and burns send budget on duplicate or junk records. This workflow puts an Agent-mode Connector node in the middle of the import path: it cleans each record, infers the missing fields, and only lets through contacts that are genuinely new before they ever reach Klaviyo.
The workflow runs on a Manual trigger, so you paste or pass a batch of raw contacts and press Run. Each contact is normalized and enriched by an Agent-mode Connector node that returns structured JSON, checked against your existing Klaviyo profiles, enriched again with a public HTTP lookup, then created in Klaviyo. The run leaves new, clean profiles in your Klaviyo account and skips anything that already exists, so re-running the same batch is safe: contacts that were imported on the first pass are filtered out on the second.
Prerequisites
- A Klaviyo connection (API key) added under Connections. See Setting Up an API Key Connection.
- The built-in http utility connector (no auth, always available) for the enrichment lookup.
- A raw contact list to import, with at least an email per contact. Names, company, and other fields can be missing or inconsistent.
- Familiarity with structured output. See How to Use Structured Output for Reliable AI Data Extraction.
Step 1: Start with a Manual trigger that carries the raw list
Add a Trigger node and set its type to Manual. The Manual trigger output is exactly the request body you pass when you press Run, so shape your input as a list of contacts under a single key. A minimal body looks like this:
{
"contacts": [
{ "name": "john SMITH", "email": "John.Smith@acme.io", "title": "VP sales" },
{ "name": "maria lopez", "email": "maria@lopez-design.com" }
]
}
Downstream nodes read the list as {{ input.contacts }}. Keep the first test batch small (two or three rows) while you build.
Step 2: Loop over each raw contact
Add a Loop node in ForEach mode and point it at {{ input.contacts }}. Everything inside the loop body runs once per contact, and the current item is available as the loop variable (for example {{ contact }}). Processing one contact at a time keeps the AI prompt small, makes the deduplication check precise, and lets you read each iteration clearly in the execution logs. For more on iteration, see Using Loop Nodes.
Step 3: Normalize and infer fields with an Agent-mode Connector node
Inside the loop, add a Connector node and switch it to Agent mode. Agent mode lets the agent reason over the messy input and return a clean object, and its Response Schema forces the output into reliable JSON instead of free text. Set the prompt to normalize and enrich a single contact, passing the raw record as {{ contact }}:
You are cleaning a single CRM contact for import.
Raw contact: {{ contact }}
- Title-case the first and last name.
- Split the full name into firstName and lastName.
- Lowercase and trim the email.
- Infer the likely company from the email domain when no company is given
(ignore free webmail domains like gmail.com and outlook.com).
- Classify the contact into one segment: "b2b", "b2c", or "unknown".
Return only the fields in the schema.
Define the Response Schema so every iteration returns the same shape:
{
"type": "object",
"properties": {
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"email": { "type": "string" },
"company": { "type": "string" },
"segment": { "type": "string", "enum": ["b2b", "b2c", "unknown"] }
},
"required": ["firstName", "lastName", "email", "segment"]
}
Set the node output variable to clean so later steps read {{ clean.email }}, {{ clean.company }}, and {{ clean.segment }}. Agent mode costs AI credits, so keep the prompt tight. To understand the trade-off, see How to Choose Between Agent Mode and Direct Mode.
Step 4: Check Klaviyo for an existing profile
Still inside the loop, add a Connector node in Direct mode, choose the Klaviyo connector, and select the list-profiles tool. The deduplication check is an exact email match using the filter field, which accepts a JSON:API filter string. Build the filter from the cleaned email:
equals(email,"{{ clean.email }}")
Set the output variable to existing. When the contact is already in Klaviyo, the returned data array holds one profile; when it is new, the array is empty. This is the value the next step branches on, so re-running the same batch will find these now-imported contacts and skip them.
Step 5: Branch on whether the contact is new
Add a Condition node that only continues when no existing profile was found. Check the length of the returned list, for example:
{{ existing.data.length }} equals 0
Wire the true branch into the enrichment and create steps below. Leave the false branch unconnected so duplicates simply fall through and the loop moves to the next contact. See Using Condition Nodes for comparison operators.
Step 6: Enrich the new contact with an HTTP lookup
On the true branch, add a Connector node in Direct mode, choose the built-in http connector, and select the http-get tool. Use it to call a public enrichment endpoint of your choice (for example a company-data API keyed on the inferred domain). Template the URL with the cleaned company or email domain:
https://your-enrichment-api.example.com/company?domain={{ clean.company }}
Set the output variable to enriched. If the endpoint needs a key, add it as a header in the node, and prefer storing the value in a Spojit variable rather than pasting it inline. Read the extra fields you want later as {{ enriched.body.industry }} or similar, matching whatever the API returns. See HTTP Requests for the full tool reference.
Step 7: Create the clean profile in Klaviyo
Finally, on the same true branch, add a Connector node in Direct mode with the Klaviyo connector and the create-profile tool. Map the normalized and enriched values into the profile attributes:
{
"email": "{{ clean.email }}",
"first_name": "{{ clean.firstName }}",
"last_name": "{{ clean.lastName }}",
"organization": "{{ clean.company }}",
"properties": {
"segment": "{{ clean.segment }}",
"industry": "{{ enriched.body.industry }}"
}
}
Custom values such as the inferred segment go under properties so they are available for Klaviyo segmentation later. If you also want to drop new profiles straight onto a list, add one more Direct-mode Klaviyo node using add-profiles-to-list after this step, sourcing the list from list-lists.
Tips
- Keep the Agent-mode prompt scoped to one contact at a time. A schema with an
enumonsegmentstops the model from inventing new category names. - Always lowercase and trim the email inside the Agent step so the
list-profilesfilter matches Klaviyo's stored value exactly. A trailing space or stray capital letter will make a known contact look new. - If your enrichment API or Klaviyo is slow on large batches, raise the contact count gradually and watch the per-iteration timing in the execution logs before importing thousands of rows.
- Use Miraxa in the designer to scaffold the shape quickly, for example: "Add a Loop over
{{ input.contacts }}, then an Agent-mode Connector node that returns firstName, lastName, email, company, and segment as JSON."
Common Pitfalls
- Quoting the filter wrong. The
filterfield needs the email wrapped in double quotes inside the function, as inequals(email,"{{ clean.email }}"). Missing quotes returns an error instead of a match. - Webmail domains as companies. Inferring a company from
gmail.comoroutlook.comcreates garbage. The prompt explicitly excludes free webmail domains; keep that instruction. - Checking the wrong field for emptiness. Branch on the returned list length (
{{ existing.data.length }}), not on the presence of the variable itself, which always exists after the call. - Treating partial matches as duplicates. This workflow deduplicates on exact email only. Two records for the same person with different emails will both import; reconcile those before the run if that matters to you.
Testing
Validate on a tiny scope first. Build a Manual trigger body with two contacts: one whose email you know is already in Klaviyo, and one that is brand new. Press Run, then open the execution logs and step through the loop. Confirm the Agent step returns clean structured JSON for both, that the list-profiles step finds the known contact and returns an empty list for the new one, that the Condition routes only the new contact onward, and that exactly one create-profile call runs. Re-run the same body and confirm nothing is created the second time, proving the deduplication holds before you point the workflow at a full list.