Contact imports
Start a contact import and follow it as a job.
Preview the buyers Ken finds at a list of companies, for a campaign's ICP
/v1/contact-imports/buyer-previewscontact-imports.buyer-previewNeeds the csv_buyer_preview feature on your workspace. Until it is on, calls answer 404.
Body: campaignId (required; another workspace's campaign is 404), companies (1 to 50, each { key?, name?, domain? } with a name or a domain, else 400 validation_failed on companies[i]) and perCompany (1 to 3, default 3). Send a longer list in parts of 50. Example: { "campaignId": 812, "companies": [{ "key": "row-2", "name": "Northwind", "domain": "northwind.com" }], "perCompany": 3 }. A company with a domain is the Kenso Search company that owns it: one Kenso Search also matches by the given name at that domain, else one whose website is the domain's home page. A company with only a page under the domain (notion.so/...) does not count, and no name match stands in for it. A company without a domain is found by a name only one company has. Unmatched companies say why (acme.com is not in Kenso Search, could not match notion.so: no company in Kenso Search has it as its own website, no domain, name matches 3 companies). A matched company lists up to perCompany people at it in the campaign's ICP (the titles, seniority, functions and exclusions of its live segments), best first, each with a one-line reason, and each person once in the whole answer, by Kenso id or LinkedIn URL (Kenso Search can hold one person twice); when the ICP finds too few, Ken fills the company from a looser search and marks those people weakMatch. No ICP ranks people by seniority, and icp is empty. signal is the company's latest hiring, funding or leadership change from the last 90 days. A company Kenso Search does not answer for in time is failed; retry it. Each person's contact is a POST /v1/contact-importssource: search contact: post the kept ones unchanged to add them (dedupe, blocklist and lead credits apply there). Nothing is stored and nothing is charged; the companies times perCompany count against the workspace's searchBrowseRows quota (429 quota_exceeded past it). Requires WriteAccess and the Import feature.
Parameters
400 workspace_required).Request body
application/json, optional.
name or a domain and an optional key echoed back.Response 200
data is a BuyerPreviewModel.
curl -X POST "https://api.ken.so/v1/contact-imports/buyer-previews" \
-H "Authorization: Bearer $KEN_API_KEY" \
-H "Ken-Version: 2026-11-02" \
-H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{
"campaignId": 812,
"companies": [
{
"key": "string",
"name": "string",
"domain": "example.com"
}
],
"perCompany": 1
}'{
"success": true,
"data": {
"icp": [
"string"
],
"companies": [
{
"key": "string",
"input": {
"name": "string",
"domain": "example.com"
},
"status": "string",
"unmatchedReason": "string",
"company": {
"kenCompanyId": 812,
"name": "string",
"domain": "example.com",
"employeeCount": 1,
"industry": "string"
},
"signal": {
"kind": "string",
"label": "string",
"at": "2026-09-23T15:02:11Z"
},
"people": [
{
"kenPersonId": 812,
"fullName": "string",
"title": "string",
"seniorityLevel": "string",
"function": "string",
"linkedInUrl": "https://example.com",
"reason": "string",
"weakMatch": true,
"contact": {}
}
]
}
]
}
}Start a contact import and return its job
/v1/contact-importscontact-imports.createJSON source is campaign (copy from sourceCampaignId), search (a contacts array, at most 1,000), blitz (the same contacts array, at most 1,000, for people picked in the Blitz tab of Search; the run's surface is Blitz and it counts no Kenso Search work) or people (personIds, 1 to 1,000 workspace people; each becomes a contact with their name, email, title, LinkedIn URL and the company of their highest-priority account; needs the accounts feature and Accounts view, and with account scope own takes only people on the caller's accounts), event (eventId, optional roles and includeAccounts: an outbound event's attendees with an email or a LinkedIn URL, at most 5,000; rows whose company is a workspace account are left out unless includeAccounts is true, and then with account scope own only those on the caller's accounts are added; needs the accounts and event_outbound features and Accounts view) or sales_navigator (linkedinAccountId, a seat the caller connected, and url, a Sales Navigator lead search or lead list; its people with their name, current title, LinkedIn profile and company. One import reads at most 8 pages (about 200 to 400 people): a list it cannot read to the end imports nothing and is 400 validation_failed with code incomplete_read on url. Behind the sales_navigator feature, a seat without Sales Navigator is 409 sales_navigator_required). Where the linkedin_network feature is on, linkedin_connections (connectionIds, 1 to 1,000 ids from GET /v1/linkedin-connections; each becomes a contact with first and last name, title, headline, LinkedIn URL, seniority and Kenso Search's company name, domain, LinkedIn URL, industry and size; needs Accounts view; elsewhere it is an unknown source). Multipart source=csv uploads a CSV file into campaignId; its optional form field columnMappings (a JSON array of mappings, as for a Google Sheet) sets the columns, and a mapped column the header row lacks is 400 validation_failed; omitted, the header row is mapped by the import's aliases. Its optional form field emailsFoundByKen=true (staff only, otherwise 403 forbidden) stamps every imported email found_by_ken, so the cross-workspace Kenso DB lookup may serve it. Behind the csv_import_check feature (a field sent while it is off is 400 validation_failed on that field) the CSV create also takes the List check choices of a scan: fixes (a JSON array of missingKey, invalidEmail, noMx, catchAll, personalEmail, roleAddress, companyContacted, companyCap) drops the rows the List check flags for them and counts each under its key in the job's skipped; includeRecentlyContacted=true keeps the rows the campaign's recently-emailed window would skip, for this import only; cleanup=true cleans the name, email, title, company, domain, LinkedIn URL and phone cells as the scan's cleanup notes say. Omitted, they are off. JSON sourcegoogle_sheets (behind the google_sheets flag) reads spreadsheetId (an id or a Google Sheets URL) and the optional sheet with the caller's own Kenso Workspace Google connection; row 1 is the header and columnMappings work as for a CSV. No connection is 409 reconnect_required; a sheet not picked for Kenso is 409 sheet_not_shared. JSON sourceairtable (behind the airtable flag) reads every record of tableId (an id or name) in baseId, or of its viewId when set, with the workspace's Airtable token: the table's field names, primary field first, are the header and each record is a row of cell text. A table or view of more than 10,000 records is 400 validation_failed on tableId: import it in parts through views. No token is 409 not_connected; a token without the read scopes or access to the base is 409 scope_required. JSON sourcesaved_list (behind the saved_lists flag; elsewhere it is an unknown source) imports the members of savedListId: a list of this workspace (a private one only for its owner), a list shared with it, or a library list. The optional memberFilters (country, seniority, title, industry, headcountBand, hasSendableEmail) narrow the members. A company list with roles (1 to 20 titles) imports up to 5 people per company found by Kenso Search, for at most 500 companies; without roles each company is one company contact. findPeople must be absent or false. Members this list already put in the campaign are skipped, so a retry adds only the rest. A library list's cached Safe emails land Safe with no email charge on a paid plan; a Free workspace gets no emails from a library list. A member copied out of a library list follows the same rules in any list. columnMappings is refused: the list sets its own columns. More than 100,000 contacts is 400 validation_failed on memberFilters. Every source applies the import's dedupe, blocklist and contact limits. A JSON body over 10 MB is 413; the multipart CSV upload accepts up to 400 MB. Requires WriteAccess. Poll GET /v1/jobs/{jobId}?type=contact_import. linkedin_url (url, optional linkedinAccountId, maxLeads, postKinds) queues a LinkedIn import that needs the linkedin_url_import flag. Options: source=campaign takes importLimit (above 0), importOffset (0 or more), onlyImportQualifiedContacts, onlyImportValidContacts, deduplicateCampaignIds (1 to 50 campaigns of the workspace; others are not_found), deduplicationMode (none, by_email the default, or by_data), allowReimport, copyDataEnrichment and copyEmailEnrichment (keep the source's enrichment results; default false). source=search (not blitz) takes importLimit, triggerPostProcessing (default true) and tagIds (1 to 50 workspace contact tags put on every imported contact). A multipart CSV takes the form fields importLimit and tagIds (a JSON array, for example [3,4]). An option sent with a source that does not honor it, or with target=people, is 400 validation_failed on that field. source=segment copies one segment of the workspace (sourceSegmentId, from any campaign) into campaignId as a new segment with the source segment's name. copyQualification, copyEmailEnrichment and copyEmailVerification keep the source contacts' AI qualification, found emails and email verification (each default false). segmentId is refused (the copy makes its own segment); a segment name already in the campaign is 400, and another import into it in progress is 409. target is campaign (the default) or people. With people (behind the lead_intake feature, 404 when off, and Accounts edit) campaignId is optional and ignored: the rows of source csv (multipart, form field target=people), search, blitz, event, linkedin_connections, sales_navigator, google_sheets or airtable are queued and run through person intake as workspace people with source import. A row needs an email, a LinkedIn URL, or a name with a business company domain; others are skipped. The job's personImport reports the created, updated and skipped counts.
Parameters
400 workspace_required).Request body
application/json, required.
memberFilters of a saved_list import. A null or empty value is no filter.Response 202
data is a JobModel.
inbox_import job. Passwords are never returned.contact_import job with target people: each row created a person, updated (matched) one, or was skipped (no email, LinkedIn URL, or name with a business company domain, or intake refused it).contact_import job skipped, by reason. The duplicate counts name the rule that dropped the row: a contact already in this campaign, a contact in another campaign of the workspace (the campaign dedups against other campaigns), an earlier row of the same import, or the campaign's recently-emailed window.curl -X POST "https://api.ken.so/v1/contact-imports" \
-H "Authorization: Bearer $KEN_API_KEY" \
-H "Ken-Version: 2026-11-02" \
-H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{
"campaignId": 812,
"source": "string",
"target": "string",
"sourceCampaignId": 812,
"segmentId": 812,
"contacts": [
{
"firstName": "string",
"lastName": "string",
"fullName": "string",
"title": "string",
"headline": "string",
"location": "string",
"linkedInUrl": "https://example.com",
"companyName": "string",
"companyDomain": "example.com",
"companyLinkedInUrl": "https://example.com",
"companyIndustry": "string",
"companyEmployeeCount": 1,
"companyDescription": "string",
"companyType": "string",
"companyFoundedYear": 1,
"companyLocation": "string",
"companySpecialties": "string",
"summary": "string",
"experiencesJson": "string",
"educationJson": "string",
"certificationsJson": "string",
"inferredSkills": "string",
"languages": "string",
"lastJobDescription": "string",
"kenCompanyId": 812,
"profilePhotoUrl": "https://example.com",
"city": "string",
"state": "string",
"country": "string",
"countryIso2": "string",
"seniorityLevel": "string",
"function": "string",
"isDecisionMaker": true,
"yearsOfExperience": 0.5,
"yearsInCurrentRole": 0.5,
"yearsInCurrentCompany": 0.5,
"changedJobRecently": true,
"jobChangeDate": "2026-09-23T15:02:11Z",
"companyFounded": 1,
"headquartersCity": "string",
"headquartersState": "string",
"headquartersCountry": "string",
"companySize": "string",
"companyLogoUrl": "https://example.com",
"companyFollowers": 1,
"companyTotalFunding": 1,
"companyLastFundingRoundType": "string",
"companyLastFundingAmount": 1,
"currentExperiences": [
{
"companyId": 812,
"companyName": "string",
"companyUrl": "https://example.com",
"companyDomain": "example.com",
"companyEmployeeCount": 1,
"title": "string",
"startDate": "2026-09-23T15:02:11Z"
}
]
}
],
"personIds": [
812
],
"eventId": 812,
"roles": [
"string"
],
"includeAccounts": true,
"connectionIds": [
812
],
"linkedinAccountId": 812,
"url": "https://example.com",
"spreadsheetId": "string",
"sheet": "string",
"baseId": "string",
"tableId": "string",
"viewId": "string",
"columnMappings": [
{
"csvColumn": "string",
"targetField": "string",
"customVariableName": "string",
"useAsAiContext": true,
"isRequiredForSending": true
}
],
"maxLeads": 1,
"postKinds": [
"string"
],
"savedListId": 812,
"memberFilters": {
"country": "string",
"seniority": "string",
"title": "string",
"industry": "string",
"headcountBand": "string",
"hasSendableEmail": true
},
"findPeople": true,
"importLimit": 1,
"importOffset": 1,
"onlyImportQualifiedContacts": true,
"onlyImportValidContacts": true,
"deduplicateCampaignIds": [
812
],
"deduplicationMode": "string",
"allowReimport": true,
"copyDataEnrichment": true,
"copyEmailEnrichment": true,
"triggerPostProcessing": true,
"tagIds": [
812
],
"sourceSegmentId": 812,
"copyQualification": true,
"copyEmailVerification": true
}'{
"success": true,
"data": {
"id": 812,
"type": "string",
"status": "string",
"progress": {
"completed": 1,
"total": 1
},
"resource": {
"type": "string",
"id": 812
},
"createdAt": "2026-09-23T15:02:11Z",
"downloadUrl": "https://example.com",
"error": {
"code": "string",
"message": "string"
},
"result": null,
"domainsListed": 1,
"excludedLibraryContacts": 1,
"inboxImport": {
"connectedCount": 1,
"failedCount": 1,
"skippedCount": 1,
"startedAt": "2026-09-23T15:02:11Z",
"updatedAt": "2026-09-23T15:02:11Z",
"completedAt": "2026-09-23T15:02:11Z",
"rows": [
{
"index": 1,
"email": "[email protected]",
"status": "string",
"inboxId": 812,
"errorCode": "string",
"message": "string"
}
]
},
"personImport": {
"source": "string",
"createdCount": 1,
"updatedCount": 1,
"skippedCount": 1,
"startedAt": "2026-09-23T15:02:11Z",
"completedAt": "2026-09-23T15:02:11Z"
},
"skipped": {
"duplicateInCampaign": 1,
"duplicateInOtherCampaign": 1,
"duplicateInSource": 1,
"alreadyContacted": 1,
"blocklist": 1,
"failedValidation": 1,
"salesNavigatorProfileUnresolved": 1,
"other": 1,
"missingKey": 1,
"invalidEmail": 1,
"noMx": 1,
"catchAll": 1,
"personalEmail": 1,
"roleAddress": 1,
"companyContacted": 1,
"companyCap": 1
}
}
}Suggest the Kenso field of every column of an import file from its header and sample rows
/v1/contact-imports/scanscontact-imports.scanNeeds the csv_import_check feature on your workspace. Until it is on, calls answer 404.
Body: source (required: csv, google_sheets or airtable), target (campaign, the default, or people), campaignId (required with target campaign: a campaign of the workspace, else 404), headers (the header row, 1 to 200 names of at most 500 characters) and sampleRows (up to 200 rows; a short row reads as blanks, extra cells are ignored, each cell at most 2,000 characters). keys (optional) holds the key columns of every row for the List check: rowCount (data rows in the file), cleanup (default true: each value first goes through the import's cleaner) and one list per mapped key field (email, profileUrl, companyDomain, companyName, companyLinkedinUrl, firstName, lastName, fullName, title, emailValidity), index-aligned, plus otherEmails (each further column mapped to email, up to 10 lists) and otherEmailValidity (the validity column mapped right after each, null where none). With target campaign every list sent has the same length, 1 to 25,000, and rowCount is at least that length (else 400 on keys); a larger file sends its first 25,000 rows and its check is truncated. A scan that sends keys skips the AI suggestions. Example: { "source": "csv", "campaignId": 42, "headers": ["Full Name", "Work contact"], "sampleRows": [["Ada Lovelace", "[email protected]"]], "keys": { "rowCount": 1, "fullName": ["Ada Lovelace"], "email": ["[email protected]"] } }. Returns 200 with columns, one per header in header order: index, csvColumn, targetField (a contact field key such as email, full_name or profile_url, custom_variable or skip), customVariableName (only for custom_variable), confidence (high: the header names the field or nearly every value is one; memory: this workspace mapped the header before; check: likely, confirm it; skip: not imported), reason (one short sentence), samples (up to 3 distinct values, each at most 120 characters) and cleanup (what the import's cleanup changes in the sample values, e.g. Cleans 3 of 10 sample values (casing, format), else null); listKind (company when no column is a person field and one is a company field, else people); and check, the List check, with keys and target campaign (else null): rows, scannedRows, truncated, cleanupApplied, always (what the import skips anyway: failedValidation, missingKey, blocklist, duplicateInFile, duplicateInCampaign, duplicateInOtherCampaign, first match wins), contacted (rows another campaign emailed inside the campaign's recently-emailed window), checks (optional fixes: invalidEmail, noMx, catchAll, personalEmail, roleAddress, companyContacted, companyCap), rowFlags ([rowIndex, mask]), noEmailRows, baselinePrior, bounce (expected, beforeFixes, band), willAdd, companyContactedDays, companyCap, domainsChecked, domainsTotal, titleMix and providerMix. Samples carry the 1-based data row; rows and row indexes are 0-based into the key lists. Kenso's own data only, no paid lookup. A field is suggested once, except email, email_validity and custom_variable. Target people suggests only the fields a people import stores: email, first_name, last_name, full_name, title, company_name, company_domain, phone_number and profile_url. Nothing is stored. Behind the csv_import_check flag (404 when off). Needs campaigns view and the import feature. Each call counts on the workspace's contactImportScans quota (30 a minute, 600 an hour, 5,000 a day; past it, 429 quota_exceeded with Retry-After).
Parameters
400 workspace_required).Request body
application/json, optional.
csv, google_sheets or airtable.campaign (the default) or people (only the fields a people import takes).Response 200
data is a ContactImportScanModel.
company when no column maps to email, profile_url, first_name, last_name or full_name and one maps to company_name, company_domain or company_linkedin_url; otherwise people.row in a sample is the 1-based data row number (header excluded); rows and the first number of each rowFlags pair are 0-based indexes into the key lists. A skipped row's reason (for a download of the skipped rows) is the first match in this order: its always bucket, then contacted when it is skipped, then the first check that is on, in checks order.curl -X POST "https://api.ken.so/v1/contact-imports/scans" \
-H "Authorization: Bearer $KEN_API_KEY" \
-H "Ken-Version: 2026-11-02" \
-H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{
"source": "string",
"target": "string",
"campaignId": 812,
"headers": [
"string"
],
"sampleRows": [
[
"string"
]
],
"keys": {
"rowCount": 1,
"cleanup": true,
"email": [
"[email protected]"
],
"profileUrl": [
"https://example.com"
],
"companyDomain": [
"example.com"
],
"companyName": [
"string"
],
"companyLinkedinUrl": [
"https://example.com"
],
"firstName": [
"string"
],
"lastName": [
"string"
],
"fullName": [
"string"
],
"title": [
"string"
],
"emailValidity": [
"string"
],
"otherEmails": [
[
"[email protected]"
]
],
"otherEmailValidity": [
[
"string"
]
]
}
}'{
"success": true,
"data": {
"columns": [
{
"index": 1,
"csvColumn": "string",
"targetField": "string",
"customVariableName": "string",
"confidence": "string",
"reason": "string",
"samples": [
"string"
],
"cleanup": "string"
}
],
"listKind": "string",
"check": {
"rows": 1,
"scannedRows": 1,
"truncated": true,
"cleanupApplied": true,
"always": [
{
"key": "string",
"label": "string",
"count": 1,
"samples": [
{}
],
"rows": [
1
]
}
],
"contacted": {
"count": 1,
"days": 1,
"skipByDefault": true,
"samples": [
{
"row": 1,
"value": "string",
"reason": "string"
}
],
"rows": [
1
]
},
"checks": [
{
"key": "string",
"label": "string",
"fixLabel": "string",
"count": 1,
"defaultOn": true,
"prior": 0.5,
"note": "string",
"samples": [
{}
]
}
],
"rowFlags": [
[
1
]
],
"noEmailRows": [
1
],
"baselinePrior": 0.5,
"bounce": {
"expected": 0.5,
"beforeFixes": 0.5,
"band": "string"
},
"willAdd": 1,
"companyContactedDays": 1,
"companyCap": 1,
"domainsChecked": 1,
"domainsTotal": 1,
"titleMix": [
{
"label": "string",
"share": 1
}
],
"providerMix": [
{
"label": "string",
"share": 1
}
]
}
}
}