Kenso
Kenso CLI

Leads

Kenso CLI pages

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.

CommandWhat it does
contacts importAdd leads from a CSV or JSONL file to a campaign, with one result per row
contacts updateEdit leads in bulk from a file, matched by id or by email
contacts remove / restoreRemove (soft-delete) leads by id, and undo it
contacts pause / resumePause and resume the campaign sequence of leads (pause is beta, pause_leads flag)
contacts tag / untagAdd or remove a tag on leads by id
contacts exportDownload 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:

FieldAlso read fromRule
emailemail address, e-mail, work emailRequired, unless the campaign is LinkedIn-only and the row has a linkedin.com/in/ URL
firstNamefirst name, first, given nameRequired
lastNamelast name, last, surname, family nameRequired
titlejob title, position, roleOptional
linkedInUrllinkedin, linkedin url, linkedin profile, profile urlOptional
companyNamecompany, organization, account, account namecompanyName or companyDomain is required
companyDomainwebsite, domain, company website, urlcompanyName 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 null clears 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:

ReasonMeaning
missing_emailThe row has no email (and is not a LinkedIn-only lead)
invalid_emailThe email is not a valid address
missing_first_nameThe row has no first name
missing_last_nameThe row has no last name
missing_companyThe row has neither a company name nor a company domain
sales_navigator_urlAn email-less row whose LinkedIn URL is a Sales Navigator link: add the email or a linkedin.com/in/ URL
duplicate_in_fileAn 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:

StatusMeaning
createdA new lead in the campaign
updatedThe lead was already there and was updated
skippedKenso kept the lead out on purpose. reason is duplicate, blocklisted, already_contacted, excluded or another code
failedKenso refused the row (reason such as invalid_email or validation, and an error message), or the call failed
invalidThe 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 id reads the contact id from an id or contactId column.
  • --match email finds each email among the leads of --campaign. An email it cannot find is not_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 null clears it.
  • The lead fields you can edit are firstName, lastName, title, linkedInUrl, and email with --match id. companyName and companyDomain cannot change here and are not sent. Every other column is a custom variable, mapped the same way as in an import, and --map works here too.
  • A row with nothing to change is skipped with reason no_changes and is not sent. Rows go 1,000 per call to POST /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's next already carries the right --confirm.
  • Each id comes back ok, skipped when there was nothing to do (for example not_deleted when you restore a contact that is not removed), not_found or failed.
  • 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