Leads are contacts in a campaign. The lead commands live under kenso contacts, and kenso leads is the same thing: kenso leads import runs kenso contacts import.
| Command | What it does |
|---|---|
contacts import | Add leads from a CSV or JSONL file to a campaign, with one result per row |
contacts update | Edit leads in bulk from a file, matched by id or by email |
contacts remove / restore | Remove (soft-delete) leads by id, and undo it |
contacts pause / resume | Pause and resume the campaign sequence of leads (pause is beta, pause_leads flag) |
contacts tag / untag | Add or remove a tag on leads by id |
contacts export | Download a campaign's leads as one CSV file |
All of them except export are batch commands: without --yes they print a plan and change nothing. Batch runs explains plans, run folders, results files and resume.
Import a CSV into a campaign
kenso contacts import --template
kenso contacts import --campaign 812 --file leads.csv --dry-run
kenso contacts import --campaign 812 --file leads.csv --yes
--template prints the accepted columns. A minimal file:
email,firstName,lastName,companyName,Pain Point
[email protected],Ann,Lee,Acme,Slow onboarding
[email protected],Cid,Moe,Globex,Churn
Columns
Headers are matched without regard to case, spaces or punctuation. The known fields and the other headers each one reads:
| Field | Also read from | Rule |
|---|---|---|
email | email address, e-mail, work email | Required, unless the campaign is LinkedIn-only and the row has a linkedin.com/in/ URL |
firstName | first name, first, given name | Required |
lastName | last name, last, surname, family name | Required |
title | job title, position, role | Optional |
linkedInUrl | linkedin, linkedin url, linkedin profile, profile url | Optional |
companyName | company, organization, account, account name | companyName or companyDomain is required |
companyDomain | website, domain, company website, url | companyName or companyDomain is required |
Every other column becomes a custom variable, named like its header in snake_case: a Pain Point column fills {{pain_point}} in your sequence. Names Kenso keeps for its own fields (city, country, phone_number and others) are not sent: the CLI warns and the plan lists them under droppedColumns. Rename them with --map.
Cells
- A blank cell is never sent, so importing a file never erases data on a lead that is already in the campaign.
- The literal value
nullclears a custom variable.
Rename or drop columns with --map
--map takes a JSON object, @file or - for stdin. A value names the target field, and null drops the column:
kenso contacts import --campaign 812 --file leads.csv --map '{"Pain": "customVariables.pain_point", "Notes": null}' --yes
JSONL
Each line is one lead. Keys work like CSV headers, and a customVariables object is spread into custom variables:
{"email":"[email protected]","firstName":"Ann","lastName":"Lee","companyName":"Acme","customVariables":{"pain_point":"Slow onboarding"}}
kenso contacts import --campaign 812 --file leads.jsonl --yes
cat leads.jsonl | kenso contacts import --campaign 812 --file - --yes
A run read from stdin cannot be resumed with kenso runs resume, so prefer a file for large imports.
What the CLI checks before sending
Every row is checked locally first. A row that fails goes to invalid.csv with one of these reasons and is not sent:
| Reason | Meaning |
|---|---|
missing_email | The row has no email (and is not a LinkedIn-only lead) |
invalid_email | The email is not a valid address |
missing_first_name | The row has no first name |
missing_last_name | The row has no last name |
missing_company | The row has neither a company name nor a company domain |
sales_navigator_url | An email-less row whose LinkedIn URL is a Sales Navigator link: add the email or a linkedin.com/in/ URL |
duplicate_in_file | An earlier row of the file has the same email |
Kenso stays the authority and checks every row again.
What the dry run shows
The dry run maps the columns, checks the rows, reads the campaign, and asks for a credit estimate (credit-estimates create, kind leads). Its plan holds rows, valid, invalid, mapping, customVariableKeys, estimate, credits (needed, available, sufficient) and next, the exact apply command. If the estimate call fails, the plan still prints with "estimate":null and an estimateError.
How rows are sent
The CLI sends 1,000 rows per call to POST /v1/campaigns/{campaignId}/contacts, one call at a time per campaign, because two overlapping upserts of the same email could create duplicates. After a 5xx answer or a dropped connection it does not retry right away: it waits 130 seconds, so the first request has finished on the server, then resends the chunk once. Rows the first request saved come back updated.
Reading the result
Each row in results.csv gets one status:
| Status | Meaning |
|---|---|
created | A new lead in the campaign |
updated | The lead was already there and was updated |
skipped | Kenso kept the lead out on purpose. reason is duplicate, blocklisted, already_contacted, excluded or another code |
failed | Kenso refused the row (reason such as invalid_email or validation, and an error message), or the call failed |
invalid | The CLI refused the row before sending it (see the table above) |
A saved lead that cannot be emailed yet has sendable false, and the summary counts those leads in notSendable with a warning on stderr.
Exit codes: 0 when no row failed (skipped rows are not failures), 5 when some rows failed or were invalid, 6 when the plan's or campaign's lead limit stopped the run (limit_reached; raise the limit, then rerun the same command to send the rest).
Import a very large file on the server
For up to 100,000 rows, --server-import uploads the CSV as one server import job and waits for it (default 30 minutes, --timeout). It reports the job's counts but no per-row results.
kenso contacts import --campaign 812 --file big.csv --server-import --yes
kenso contacts import --campaign 812 --file big.csv --server-import --timeout 1h --yes
Update leads in bulk
kenso contacts update --file edits.csv --match id
kenso contacts update --file edits.csv --match id --yes
kenso contacts update --file edits.csv --match email --campaign 812 --yes
--match idreads the contact id from anidorcontactIdcolumn.--match emailfinds each email among the leads of--campaign. An email it cannot find isnot_found. The email column only finds the lead; it is never sent as an edit.- A column that is absent, or a blank cell, leaves that field unchanged. The literal
nullclears it. - The lead fields you can edit are
firstName,lastName,title,linkedInUrl, andemailwith--match id.companyNameandcompanyDomaincannot change here and are not sent. Every other column is a custom variable, mapped the same way as in an import, and--mapworks here too. - A row with nothing to change is
skippedwith reasonno_changesand is not sent. Rows go 1,000 per call toPOST /v1/contacts/bulk-update.
To edit one lead, use a one-row file from stdin:
printf 'id,title\n9001,VP Sales\n' | kenso contacts update --file - --match id --yes
Remove, restore, pause and resume
These four take contact ids from --ids (one id per line, or a CSV or JSONL file with an id or contactId column, or - for stdin), or from a search with --where (remove, pause and resume). --where takes the body of POST /v1/contacts/search as JSON, @file or -.
kenso contacts remove --ids ids.txt
kenso contacts remove --ids ids.txt --yes
kenso contacts remove --where '{"campaignIds": [812], "q": "acme"}' --yes
kenso contacts remove --ids big.csv --confirm 2500 --yes
- Without
--yes, the plan prints the count and the first ids. - Removing more than 1,000 contacts also needs
--confirm <count>, and the count must match. The plan'snextalready carries the right--confirm. - Each id comes back
ok,skippedwhen there was nothing to do (for examplenot_deletedwhen you restore a contact that is not removed),not_foundorfailed. - The remove summary carries the undo command,
"undo":"kenso contacts restore --run r_ubqu7wtx".
kenso contacts restore --run r_ubqu7wtx --yes
kenso contacts restore --ids ids.txt --yes
kenso contacts pause --where '{"campaignIds": [812]}' --yes
kenso contacts resume --ids ids.txt --yes
restore takes --ids or --run <runId> of an earlier remove, not --where: a search cannot find removed contacts. pause is a beta command for workspaces with the pause_leads flag.
Tag and untag
kenso tags list
kenso contacts tag --tag 31 --ids ids.txt --yes
kenso contacts untag --tag 31 --ids ids.txt --yes
tag calls POST /v1/tags/{tagId}/assignments once per contact. A contact that already has the tag is skipped with reason already_tagged.
Export a campaign's leads
kenso contacts export --campaign 812 --out contacts.csv
kenso contacts export --campaign 812 --wait
With --out, the CLI creates the export, waits for the job (default 30 minutes, --timeout) and writes one CSV file (mode 0600). Without --out it prints the export job; --wait waits for it, or run the next it prints, kenso jobs wait <jobId> --type contact_export.
Recipes
Tag every lead of a campaign. List the ids, then tag them:
kenso contacts list --campaign-id 812 --all -o ids > ids.txt
kenso contacts tag --tag 31 --ids ids.txt --yes
Fix the invalid rows and send them. invalid.csv keeps each refused row's number and reason. Fix those rows in a new file and import it. Rerunning the original file also works: rows Kenso already saved come back updated or skipped.
Retry only what failed. Filter results.csv to the failed rows, take those rows from the input by row number, and import that file.
Pause leads who replied elsewhere. Search by filter, check the count, then apply:
kenso contacts pause --where @replied.json
kenso contacts pause --where @replied.json --yes
Edit one field on many leads by email.
kenso contacts update --file titles.csv --match email --campaign 812 --dry-run
kenso contacts update --file titles.csv --match email --campaign 812 --yes