How to Enrich a CSV Lead List and Import It into Klaviyo
Build a scheduled Spojit workflow that downloads a lead CSV from FTP, validates and dedupes the rows, then loops over each lead to create a Klaviyo profile and add it to an import list.
What This Integration Does
Marketing teams often receive lead lists as CSV files dropped onto an FTP server by an event platform, a list broker, or a partner. Doing the import by hand is slow and error prone: rows arrive with blank or malformed email addresses, the same person shows up twice, and someone still has to paste everyone into Klaviyo and add them to the right list. This workflow removes all of that manual handling. It picks the file up on a schedule, cleans it, and pushes every valid lead into Klaviyo as a profile that lands in a list your campaigns can target.
The run model is time based. A Schedule trigger fires on a cron expression (for example every morning), the FTP connector downloads the latest CSV, the CSV utility parses and dedupes it, and a Loop node walks the cleaned rows. For each row the workflow calls the Klaviyo connector twice: once to create the profile and once to add that profile to your import list. Klaviyo treats create-profile as an upsert by email, so re-runs on an overlapping file update existing profiles rather than creating duplicates. To keep the workflow tidy, the per-lead logic lives in a child workflow that you invoke with a Subworkflow node, so the same import step can be reused by other workflows.
Prerequisites
- An FTP connection in Spojit pointing at the server where the lead CSV is dropped, with read access to the directory that holds the file. See the FTP/SFTP connector article.
- A Klaviyo connection authorized with a private API key that can write profiles and manage lists. See the Klaviyo connector article.
- A Klaviyo list to receive the leads. Note its list ID (you can confirm it with the
list-liststool). This is your "import list". - The exact remote path and column layout of the CSV (for example an
emailcolumn plusfirst_nameandlast_name). - A second, empty workflow to hold the per-lead import logic that the Subworkflow node will call.
Step 1: Start on a schedule
In the parent workflow, add a Trigger node and set its type to Schedule. Enter a 5-field cron expression and an IANA timezone so the run lands when the file is ready. For a weekday 7:00 AM run in Sydney, use:
Cron: 0 7 * * 1-5
Timezone: Australia/Sydney
A single Schedule trigger can hold more than one schedule, so you can add an extra entry (for example a midday catch-up) without building a second workflow. The trigger output is {{ scheduledAt }}, which you can stamp into log messages or file names if needed.
Step 2: Download the CSV from FTP
Add a Connector node in Direct mode, choose your FTP connection, and select the download-file tool. Map the inputs:
path- the remote file path, for example/leads/incoming/leads.csv.encoding- leave asutf8because a CSV is text, not binary.
Name the output variable download. The file text is returned as {{ download.data.content }}, and {{ download.data.size }} gives the decoded byte length, which is handy for a later sanity check.
Step 3: Parse, validate, and dedupe the rows
Add a Connector node in Direct mode using the built-in CSV utility, and select the parse tool. Pass {{ download.data.content }} as the CSV text so the file becomes an array of row objects keyed by header. Save the output as parsed.
Next add another CSV dedupe node to remove repeat leads. Run it against the parsed rows and key the dedupe on the email column so each address appears once:
Column: email
To drop rows with a missing or malformed address, add a CSV filter node, or for stricter checking add a Condition node inside the loop in Step 5 that uses the validation utility's email tool on {{ lead.email }} and skips rows that fail. Save the cleaned rows as leads.
Step 4: Build a reusable per-lead import child workflow
Open your second (empty) workflow. Give it a Manual trigger so its output is the request body passed in by the parent. Inside it, add two Connector nodes in Direct mode on your Klaviyo connection.
The first uses create-profile. Its attributes field takes a JSON object describing the lead; map it from the input the parent will send:
{
"email": "{{ input.email }}",
"first_name": "{{ input.firstName }}",
"last_name": "{{ input.lastName }}"
}
Save the result as profile. The created profile's ID is available at {{ profile.data.id }}.
The second node uses add-profiles-to-list to drop the new profile into your import list:
listId- your import list ID, for exampleRbkXyz.profileIds- an array containing the new ID:[ "{{ profile.data.id }}" ].
This per-lead logic now lives in its own workflow so it can be reused and tested independently. To learn more about decomposing logic this way, see building a reusable subworkflow library.
Step 5: Loop over leads and invoke the child workflow
Back in the parent workflow, add a Loop node set to ForEach and point it at the cleaned rows, {{ leads }}. Inside the loop body, each row is exposed as {{ lead }}.
Inside the loop, add a Subworkflow node. Set Workflow to the per-lead import workflow from Step 4, and map its Input from the current row:
{
"email": "{{ lead.email }}",
"firstName": "{{ lead.first_name }}",
"lastName": "{{ lead.last_name }}"
}
For each iteration the parent pauses, the child runs through its own trigger and Klaviyo nodes, and its final output returns to the loop before the next lead. Each child run appears as its own entry in execution history, which makes a single failed lead easy to spot without losing the rest of the batch.
Step 6: Send a completion summary
After the loop, add a Send Email node so the team knows the import ran. It sends from Spojit's built-in mail service, so no connection is required. Set Recipients, a templated Subject, and a Body that reports the counts:
Subject: Klaviyo lead import complete - {{ scheduledAt }}
Body: Imported {{ leads.length }} leads into the Klaviyo import list.
External recipients must be on your org allowlist under Settings > General > Email recipients. If you prefer to use the parent's own counters rather than embedding logic, ask Miraxa, the intelligent layer across your automation, to add the Send Email node and wire the variables for you.
Tips
- Klaviyo rate limits write calls. If your CSV is large, the per-lead Subworkflow pattern naturally paces the calls; for very large files, split the import across more than one scheduled run rather than pushing thousands of rows at once.
create-profileupserts by email, so it is safe to re-run on an overlapping file. Use this to keep names and properties fresh rather than worrying about duplicates.- Keep the dedupe step before the loop. Removing repeat emails up front saves both Klaviyo calls and AI-free processing time.
- If you add the Condition + validation email check, route invalid rows to a counter so your summary email can report how many leads were skipped.
Common Pitfalls
- Cron is in the timezone you select, not the server's. A blank or wrong IANA timezone is the most common reason a schedule fires at an unexpected hour.
- If the column headers in the CSV change (for example
Emailversusemail), your variable references break silently and profiles arrive with blank fields. Confirm the exact header casing after parsing. - Downloading a binary or compressed file with
encodingset toutf8corrupts it. This workflow assumes a plain-text CSV; only switch tobase64for genuinely binary files. add-profiles-to-listneeds the list ID, not the list name. Confirm the ID withlist-listsbefore hardcoding it.
Testing
Before scheduling it for real, point the FTP download-file path at a small test CSV with three or four rows, including one duplicate and one blank email. Temporarily switch the trigger to Manual and use the Run button so you can inspect each step's output in the execution log. Confirm the dedupe and validation steps trimmed the bad rows, then check Klaviyo to verify the profiles were created and added to the import list. Once the small batch behaves, restore the Schedule trigger and point it at the production file.