Kenso

Threads

Inbox threads across email and LinkedIn: count and list them, read messages, reply, forward, update in bulk and keep drafts.

Inbox13 endpointsKen-Version: 2026-11-02AuthenticationWorkspacesErrors

Requires Inbox view. Email and LinkedIn together. status, folder, isArchived and isSaved name the tab the way GET /v1/threads takes them, and allCount is the number of distinct threads that list returns for the tab. The list's pagination.total counts email messages, so it is higher when a thread has several messages in the tab. accountsCount and campaignsCount split the tab by account-linked and campaign threads, and campaigns lists every campaign with a thread in the inbox. toReplyCount, unreadCount, latestMessageAt and inboxChangedAt ignore the tab filters. inboxChangedAt moves when a reply arrives (a late-synced one included) or changes tab, for example when the classifier files it; poll it and refetch the list when it changes. An email thread is counted once however many messages it has; a LinkedIn conversation is one thread.

Parameters

Name
In
Type
Required
Description
Status
In: query
string
optional
Inbox tab token: to_reply, other, automated_reply, not_interested, human_reply.
Folder
In: query
string
optional
Inbox folder: inbox, sent, or all. Omitted means inbound.
IsArchived
In: query
boolean
optional
IsSaved
In: query
boolean
optional
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Response 200

data is a ThreadCountsModel.

Field
Type
Description
allCount
integer (int64)
Distinct threads in the requested tab, as GET /v1/threads lists them with the same tab filters.
accountsCount
integer (int64)
Threads in the requested tab that are linked to an account.
campaignsCount
integer (int64)
Threads in the requested tab that belong to a campaign.
campaigns
array of ThreadCampaignCountModel
Every campaign with a thread in the inbox, newest activity first. threadCount is 0 when none is in the requested tab.
toReplyCount
integer (int64)
Threads on the To Reply tab.
unreadCount
integer (int64)
Inbox threads with an unread inbound message.
latestMessageAt
string (date-time)
Newest inbound message in the inbox. Omitted when the inbox is empty.
inboxChangedAt
string (date-time)
When the inbox last changed: the newest insert or update of an inbound email (a reply synced late, the classifier moving a reply between tabs, a read), or the newest update of a LinkedIn conversation whose last message is inbound. Poll it and refetch when it changes; it is not a message time. Omitted when the inbox is empty.
Request
curl -X GET "https://api.ken.so/v1/threads/counts" \
  -H "Authorization: Bearer $KEN_API_KEY" \
  -H "Ken-Version: 2026-11-02" \
  -H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID"
Response 200
{
  "success": true,
  "data": {
    "allCount": 1,
    "accountsCount": 1,
    "campaignsCount": 1,
    "campaigns": [
      {
        "campaignId": 812,
        "campaignName": "string",
        "threadCount": 1
      }
    ],
    "toReplyCount": 1,
    "unreadCount": 1,
    "latestMessageAt": "2026-09-23T15:02:11Z",
    "inboxChangedAt": "2026-09-23T15:02:11Z"
  }
}

Mark every unread thread in the inbox read

POST/v1/threads/mark-all-readthreads.mark-all-read

Try it on developer.ken.so

Requires Inbox edit, like PATCH /v1/threads/{threadId}. Takes no body. Marks every unread inbound email message and every unread LinkedIn conversation the inbox shows, in one pass, and returns how many changed. Safe to retry: a repeat call returns updatedCount: 0.

Parameters

Name
In
Type
Required
Description
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Response 200

data is a MarkAllThreadsReadModel.

Field
Type
Description
updatedCount
integer (int64)
Email messages and LinkedIn conversations changed. A repeat call returns 0.
Request
curl -X POST "https://api.ken.so/v1/threads/mark-all-read" \
  -H "Authorization: Bearer $KEN_API_KEY" \
  -H "Ken-Version: 2026-11-02" \
  -H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID"
Response 200
{
  "success": true,
  "data": {
    "updatedCount": 1
  }
}

Requires ReadAccess. channel is email, linkedin, or all. limit defaults to 50 and maxes at 100, matching the inbox list. status and folder are the inbox tab filters (to_reply, other, automated_reply, not_interested, sent) and are applied before paging. Each thread is one row, its newest message in the tab, and pagination.total counts threads, as GET /v1/threads/counts does. With q, pagination.total counts at most 1,000 threads: past 1,000 it is 1,000 and pagination.isTotalCapped is true, and paging goes on while hasMore is true. Without q, or at 1,000 matches or fewer, the total is exact and isTotalCapped is omitted. To Reply is attributed Interested, Objection, and Wrong Contact, plus due reminders. A thread leaves it once our reply is its newest message, as a LinkedIn conversation does, and comes back with the lead's next reply. source is the lane on top of the tab: accounts (account touch threads) or campaigns (campaign threads), with the predicates of accountsCount and campaignsCount on GET /v1/threads/counts. Omitted lists every thread of the tab. A row of an account touch's thread carries accountId and accountName while the accounts feature is on for the caller, and an email one replyTouchId, the account's open reply touch answering the thread, when there is one. Every row carries hasDraft, and draftAuthor (user or agent) when a reply draft is saved on it. sort=oldest_waiting lists the thread that has waited longest first, across pages: by waitingSince, else by the newest message; omitted keeps the inbox order, newest first. Each row whose newest message is the contact's carries waitingSince, when that message arrived, on any tab; it is a wait on a reply only on to_reply. A thread we answered whose reminder came due waits since that reminder.

Parameters

Name
In
Type
Required
Description
Channel
In: query
string
optional
CampaignId
In: query
integer (int64)
optional
InboxId
In: query
integer (int64)
optional
ContactId
In: query
integer (int64)
optional
Interest
In: query
string
optional
Status
In: query
string
optional
Inbox tab token: to_reply, other, automated_reply, not_interested, human_reply. Applied in the list query before paging. Same tokens as GET /v1/inbox/replies.
Folder
In: query
string
optional
Inbox folder: inbox, sent, or all. Omitted means inbound, matching the legacy list.
Source
In: query
string
optional
The inbox lane on top of the tab: accounts or campaigns, matching accountsCount and campaignsCount of GET /v1/threads/counts. Omitted means every thread of the tab. Any other value is 400.
Sort
In: query
string
optional
oldest_waiting: the thread that has waited longest first, across pages, by each row's waitingSince, else its newest message. Omitted keeps the inbox order, newest first. Any other value is 400.
IsRead
In: query
boolean
optional
IsArchived
In: query
boolean
optional
IsSaved
In: query
boolean
optional
HasReminder
In: query
boolean
optional
TagIds
In: query
array of integer (int64)
optional
Q
In: query
string
optional
Cursor
In: query
string
optional
Limit
In: query
integer (int32)
optional
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Response 200

data is a list of ThreadModel. Each item has these fields. The response also carries pagination.

Field
Type
Description
id
string
channel
string
direction
string
GET /v1/threads only: who sent the newest message the row stands for, inbound (the contact) or outbound (the workspace). On an outbound email row fromName is ours, so the contact is the first of to; a LinkedIn row's fromName is the contact either way. Omitted from threads.get and updates, whose messages each carry their own direction.
campaignId
integer (int64)
contactId
integer (int64)
accountId
integer (int64)
The account this thread belongs to: the account whose touch the thread is (the newest touch's, DEV-1606), on lists and threads.get. threads.get falls back to the account on which the thread's contact (matched by email, else LinkedIn URL) is a stakeholder. Absent when there is none, outside the caller's account scope, or while the accounts feature is off for the caller.
accountName
string
The name of Ken.Scraper.API.Canonical.ThreadModel.AccountId. Absent with it.
replyTouchId
integer (int64)
DEV-2537, an email thread that is an account touch's thread, on lists and threads.get: that account's open reply touch (reason reply; planned, drafted, awaiting_approval or approved) answering a message in this thread, the newest when there are several. Send the reply by approving it (POST /v1/touches/{touchId}/approve) so the account's plan records it. Absent with Ken.Scraper.API.Canonical.ThreadModel.AccountId, on LinkedIn threads, when there is none or the read failed, and from updates.
hasDraft
boolean
A reply draft is saved on the thread, whoever wrote it; GET /v1/threads/{threadId}/draft returns it. On lists, threads.get and updates. False when the draft read failed.
draftAuthor
string
Who last wrote that draft: user (a person in the dashboard) or agent (an agent, MCP token or API key). Absent without a draft.
inboxId
integer (int64)
subject
string
preview
string
isRead
boolean
isArchived
boolean
isSaved
boolean
interest
string
reminderAt
string (date-time)
lastMessageAt
string (date-time)
waitingSince
string (date-time)
GET /v1/threads only: when the contact's newest message arrived, on any tab. It is the wait on a reply only on the To Reply tab: a thread marked done or archived still carries it. A thread we answered whose reminder came due is on To Reply for that reminder, so it carries the reminder's due time, not the time of the reply we answered. Omitted when the newest message is ours and no reminder is due, and from threads.get .
updatedAt
string (date-time)
senderName
string
The same name as fromName.
fromName
string
Email: the sender of the thread's newest message, ours when that message is outbound. LinkedIn: the contact's name, on lists and threads.get; absent when the conversation names nobody.
fromEmailAddress
string
htmlBody
string
tags
array of ThreadTagModel
to
array of ThreadRecipientModel
cc
array of ThreadRecipientModel
bcc
array of ThreadRecipientModel
scheduledMessages
array of ScheduledMessageModel
GET /v1/threads/{threadId} only, email: messages queued on this thread that have not sent yet (scheduled, sending, or awaiting_approval with approverMemberId, DEV-2043), oldest due first. An empty list means none are pending. Omitted from lists, from updates, and when the pending read failed.
referral
ThreadReferralModel
DEV-1274 (contract C8): the person a recruiting reply points to when its interest is referral. Every part is copied from the reply itself; a part the reply does not give is null. The app shows it as a candidate to add (Task 24).
language
string
The ISO 639-1 code of the newest inbound message (the classifier's language, else detected from the message's own words). On GET /v1/threads an email row carries its own message's, so a row whose newest message is ours has none. Omitted when unknown.
Request
curl -X GET "https://api.ken.so/v1/threads" \
  -H "Authorization: Bearer $KEN_API_KEY" \
  -H "Ken-Version: 2026-11-02" \
  -H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID"
Response 200
{
  "success": true,
  "data": [
    {
      "id": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
      "channel": "email",
      "direction": "inbound",
      "campaignId": 4821,
      "contactId": 90125,
      "hasDraft": false,
      "inboxId": 5530,
      "subject": "Re: Onboarding new engineers at Northwind Labs",
      "preview": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?",
      "isRead": false,
      "isArchived": false,
      "isSaved": false,
      "interest": "interested",
      "lastMessageAt": "2026-10-13T16:22:05Z",
      "waitingSince": "2026-10-13T16:22:05Z",
      "updatedAt": "2026-10-13T16:22:05Z",
      "senderName": "Amanda Chen",
      "fromName": "Amanda Chen",
      "fromEmailAddress": "[email protected]",
      "htmlBody": "<p>Thanks Alex, this is timely. We are hiring 12 engineers this quarter.</p><p>Could we talk Thursday afternoon?</p><p>Amanda</p>",
      "tags": [
        {
          "id": 341,
          "name": "Hot lead",
          "color": "#FFD6A5"
        }
      ],
      "to": [
        {
          "name": "Alex Rivera",
          "address": "[email protected]"
        }
      ],
      "language": "en"
    }
  ],
  "pagination": {
    "nextCursor": "eyJpZCI6ODE1fQ",
    "hasMore": true,
    "total": 1,
    "isTotalCapped": true
  }
}

Start an email thread to one person from a shared cold inbox

POST/v1/threadsthreads.create

Needs the agent_send feature on your workspace. Until it is on, calls answer 404.

Try it on developer.ken.so

For an agent or API key. Dark: 404 until the agent_send flag is on for the workspace. Idempotency-Key (1 to 200 characters) is required. A retry with the same key and request returns the original message with 201 and sends nothing; the key with another request is 409 idempotency_key_reused; a send still in flight is 409 in_progress with Retry-After: 30; a send the provider never confirmed is 409 outcome_unknown and is never resent: read the inbox's threads with the person before sending again under a new key. The send goes through the agent guardrails and the inbox's day cap. Until a host registers the guardrails (PR A-3) every send is refused with 503 agent_guardrails_unavailable and nothing is reserved or written. A refusal is the canonical error whose details[0].code is the reason, fix says what to do, and nextAllowedAt (also Retry-After) says when to retry. Sending from a member's own mailbox is refused. The warmup gate never refuses: an inbox that has not finished warming up sends, and the 201 carries a warnings entry (warmup_not_ready, reasontoo_young or unconfirmed, fix, and readyAt for too_young). A replay carries no warnings. Requires the same permission as replying in a thread (POST /v1/threads/{threadId}/messages), so every seat that can reply, Client included, can start one. The send is recorded only after the provider accepts it.

Parameters

Name
In
Type
Required
Description
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Request body

application/json, optional.

Field
Type
Required
Description
senderInboxId
integer (int64)
optional
A shared cold inbox of the workspace (GET /v1/inboxes). Never a member's own mailbox.
to
CreateThreadRecipient
optional
subject
string
optional
body
string
optional
The email body as HTML.

Response 201

data is a ThreadMessageModel.

Field
Type
Description
id
string
threadId
string
channel
string
direction
string
subject
string
body
string
extractedText
string
Inbound email only: the sender's own words, with quoted history, signature, mobile footer and sign-off removed. Omitted for outbound email, LinkedIn, and a message with no readable body. body keeps the full text.
language
string
DEV-5312, inbound messages only: the message's ISO 639-1 code, the classifier's language, else detected from its own words. Omitted when unknown and on outbound messages.
occurredAt
string (date-time)
warnings
array of ThreadMessageWarningModel
Set on POST /v1/threads/{threadId}/messages when the reply went out past a safeguard the caller confirmed, and on POST /v1/threads when the new thread went out from an inbox that has not finished warming up. Omitted otherwise.
senderName
string
fromName
string
fromEmailAddress
string
htmlBody
string
tags
array of ThreadTagModel
to
array of ThreadRecipientModel
cc
array of ThreadRecipientModel
bcc
array of ThreadRecipientModel
openCount
integer (int32)
Outbound email only: open-pixel hits excluding known proxies and crawlers. 0 when not opened or not tracked.
rawOpenCount
integer (int32)
Outbound email only: raw open-pixel hits, proxies and prefetch included. Always at least Ken.Scraper.API.Canonical.ThreadMessageModel.OpenCount.
firstOpenedAt
string (date-time)
Outbound email only: the first recorded open.
lastOpenedAt
string (date-time)
Outbound email only: the latest recorded open.
referral
ThreadReferralModel
DEV-1274 (contract C8): the person a recruiting reply points to when its interest is referral. Every part is copied from the reply itself; a part the reply does not give is null. The app shows it as a candidate to add (Task 24).
attachments
array of MessageAttachmentModel
DEV-4082, GET /v1/threads/{threadId}/messages, email only: the message's files, inline images included. Download one with GET /v1/attachments/{id}/content. Omitted when the message has none.
Request
curl -X POST "https://api.ken.so/v1/threads" \
  -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 '{
  "senderInboxId": 812,
  "to": {
    "address": "string",
    "name": "string"
  },
  "subject": "string",
  "body": "string"
}'
Response 201
{
  "success": true,
  "data": {
    "id": "msg_ZW1haWw6NDAyMjg0LjJAZXhhbXBsZS5jb20",
    "threadId": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
    "channel": "email",
    "direction": "inbound",
    "subject": "Re: Onboarding new engineers at Northwind Labs",
    "body": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?\n\nAmanda\n\nOn Tue, Oct 13, 2026 at 9:04 AM Alex Rivera <[email protected]> wrote:\n> Hi Amanda, following up on this.",
    "extractedText": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?",
    "language": "en",
    "occurredAt": "2026-10-13T16:22:05Z",
    "senderName": "Amanda Chen",
    "fromName": "Amanda Chen",
    "fromEmailAddress": "[email protected]",
    "htmlBody": "<p>Thanks Alex, this is timely. We are hiring 12 engineers this quarter.</p><p>Could we talk Thursday afternoon?</p><p>Amanda</p>",
    "tags": [
      {
        "id": 341,
        "name": "Hot lead",
        "color": "#FFD6A5"
      }
    ],
    "to": [
      {
        "name": "Alex Rivera",
        "address": "[email protected]"
      }
    ]
  }
}

Return one thread

GET/v1/threads/{threadId}threads.get

Try it on developer.ken.so

A malformed id, unknown channel, or cross-workspace thread is not_found. accountId and accountName are the account whose touch the thread is, else the account on which the thread's contact is a stakeholder, when there is one. An email thread that is an account touch's thread also carries replyTouchId: that account's open reply touch answering the thread, which sends the reply when approved . hasDraft says a reply draft is saved on the thread and draftAuthor who wrote it. Requires ReadAccess. An email thread carries scheduledMessages: replies and forwards queued on it that have not sent yet, including a teammate's reply waiting on a client seat (awaiting_approval, DEV-2043), which the writer can withdraw with POST /v1/scheduled-messages/{id}/cancel.

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Response 200

data is a ThreadModel.

Field
Type
Description
id
string
channel
string
direction
string
GET /v1/threads only: who sent the newest message the row stands for, inbound (the contact) or outbound (the workspace). On an outbound email row fromName is ours, so the contact is the first of to; a LinkedIn row's fromName is the contact either way. Omitted from threads.get and updates, whose messages each carry their own direction.
campaignId
integer (int64)
contactId
integer (int64)
accountId
integer (int64)
The account this thread belongs to: the account whose touch the thread is (the newest touch's, DEV-1606), on lists and threads.get. threads.get falls back to the account on which the thread's contact (matched by email, else LinkedIn URL) is a stakeholder. Absent when there is none, outside the caller's account scope, or while the accounts feature is off for the caller.
accountName
string
The name of Ken.Scraper.API.Canonical.ThreadModel.AccountId. Absent with it.
replyTouchId
integer (int64)
DEV-2537, an email thread that is an account touch's thread, on lists and threads.get: that account's open reply touch (reason reply; planned, drafted, awaiting_approval or approved) answering a message in this thread, the newest when there are several. Send the reply by approving it (POST /v1/touches/{touchId}/approve) so the account's plan records it. Absent with Ken.Scraper.API.Canonical.ThreadModel.AccountId, on LinkedIn threads, when there is none or the read failed, and from updates.
hasDraft
boolean
A reply draft is saved on the thread, whoever wrote it; GET /v1/threads/{threadId}/draft returns it. On lists, threads.get and updates. False when the draft read failed.
draftAuthor
string
Who last wrote that draft: user (a person in the dashboard) or agent (an agent, MCP token or API key). Absent without a draft.
inboxId
integer (int64)
subject
string
preview
string
isRead
boolean
isArchived
boolean
isSaved
boolean
interest
string
reminderAt
string (date-time)
lastMessageAt
string (date-time)
waitingSince
string (date-time)
GET /v1/threads only: when the contact's newest message arrived, on any tab. It is the wait on a reply only on the To Reply tab: a thread marked done or archived still carries it. A thread we answered whose reminder came due is on To Reply for that reminder, so it carries the reminder's due time, not the time of the reply we answered. Omitted when the newest message is ours and no reminder is due, and from threads.get .
updatedAt
string (date-time)
senderName
string
The same name as fromName.
fromName
string
Email: the sender of the thread's newest message, ours when that message is outbound. LinkedIn: the contact's name, on lists and threads.get; absent when the conversation names nobody.
fromEmailAddress
string
htmlBody
string
tags
array of ThreadTagModel
to
array of ThreadRecipientModel
cc
array of ThreadRecipientModel
bcc
array of ThreadRecipientModel
scheduledMessages
array of ScheduledMessageModel
GET /v1/threads/{threadId} only, email: messages queued on this thread that have not sent yet (scheduled, sending, or awaiting_approval with approverMemberId, DEV-2043), oldest due first. An empty list means none are pending. Omitted from lists, from updates, and when the pending read failed.
referral
ThreadReferralModel
DEV-1274 (contract C8): the person a recruiting reply points to when its interest is referral. Every part is copied from the reply itself; a part the reply does not give is null. The app shows it as a candidate to add (Task 24).
language
string
The ISO 639-1 code of the newest inbound message (the classifier's language, else detected from the message's own words). On GET /v1/threads an email row carries its own message's, so a row whose newest message is ours has none. Omitted when unknown.
Request
curl -X GET "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20" \
  -H "Authorization: Bearer $KEN_API_KEY" \
  -H "Ken-Version: 2026-11-02" \
  -H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID"
Response 200
{
  "success": true,
  "data": {
    "id": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
    "channel": "email",
    "direction": "inbound",
    "campaignId": 4821,
    "contactId": 90125,
    "hasDraft": false,
    "inboxId": 5530,
    "subject": "Re: Onboarding new engineers at Northwind Labs",
    "preview": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?",
    "isRead": false,
    "isArchived": false,
    "isSaved": false,
    "interest": "interested",
    "lastMessageAt": "2026-10-13T16:22:05Z",
    "waitingSince": "2026-10-13T16:22:05Z",
    "updatedAt": "2026-10-13T16:22:05Z",
    "senderName": "Amanda Chen",
    "fromName": "Amanda Chen",
    "fromEmailAddress": "[email protected]",
    "htmlBody": "<p>Thanks Alex, this is timely. We are hiring 12 engineers this quarter.</p><p>Could we talk Thursday afternoon?</p><p>Amanda</p>",
    "tags": [
      {
        "id": 341,
        "name": "Hot lead",
        "color": "#FFD6A5"
      }
    ],
    "to": [
      {
        "name": "Alex Rivera",
        "address": "[email protected]"
      }
    ],
    "language": "en"
  }
}

Update thread fields

PATCH/v1/threads/{threadId}threads.update

Try it on developer.ken.so

Absent fields stay unchanged. Null reminderAt clears a reminder.

Requires InboxReplyAccess, matching inbox triage. The thread comes back with hasDraft and draftAuthor, as threads.get serves them; accountId, replyTouchId and scheduledMessages are left out.

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Request body

application/json, optional: object.

Response 200

data is a ThreadModel.

Field
Type
Description
id
string
channel
string
direction
string
GET /v1/threads only: who sent the newest message the row stands for, inbound (the contact) or outbound (the workspace). On an outbound email row fromName is ours, so the contact is the first of to; a LinkedIn row's fromName is the contact either way. Omitted from threads.get and updates, whose messages each carry their own direction.
campaignId
integer (int64)
contactId
integer (int64)
accountId
integer (int64)
The account this thread belongs to: the account whose touch the thread is (the newest touch's, DEV-1606), on lists and threads.get. threads.get falls back to the account on which the thread's contact (matched by email, else LinkedIn URL) is a stakeholder. Absent when there is none, outside the caller's account scope, or while the accounts feature is off for the caller.
accountName
string
The name of Ken.Scraper.API.Canonical.ThreadModel.AccountId. Absent with it.
replyTouchId
integer (int64)
DEV-2537, an email thread that is an account touch's thread, on lists and threads.get: that account's open reply touch (reason reply; planned, drafted, awaiting_approval or approved) answering a message in this thread, the newest when there are several. Send the reply by approving it (POST /v1/touches/{touchId}/approve) so the account's plan records it. Absent with Ken.Scraper.API.Canonical.ThreadModel.AccountId, on LinkedIn threads, when there is none or the read failed, and from updates.
hasDraft
boolean
A reply draft is saved on the thread, whoever wrote it; GET /v1/threads/{threadId}/draft returns it. On lists, threads.get and updates. False when the draft read failed.
draftAuthor
string
Who last wrote that draft: user (a person in the dashboard) or agent (an agent, MCP token or API key). Absent without a draft.
inboxId
integer (int64)
subject
string
preview
string
isRead
boolean
isArchived
boolean
isSaved
boolean
interest
string
reminderAt
string (date-time)
lastMessageAt
string (date-time)
waitingSince
string (date-time)
GET /v1/threads only: when the contact's newest message arrived, on any tab. It is the wait on a reply only on the To Reply tab: a thread marked done or archived still carries it. A thread we answered whose reminder came due is on To Reply for that reminder, so it carries the reminder's due time, not the time of the reply we answered. Omitted when the newest message is ours and no reminder is due, and from threads.get .
updatedAt
string (date-time)
senderName
string
The same name as fromName.
fromName
string
Email: the sender of the thread's newest message, ours when that message is outbound. LinkedIn: the contact's name, on lists and threads.get; absent when the conversation names nobody.
fromEmailAddress
string
htmlBody
string
tags
array of ThreadTagModel
to
array of ThreadRecipientModel
cc
array of ThreadRecipientModel
bcc
array of ThreadRecipientModel
scheduledMessages
array of ScheduledMessageModel
GET /v1/threads/{threadId} only, email: messages queued on this thread that have not sent yet (scheduled, sending, or awaiting_approval with approverMemberId, DEV-2043), oldest due first. An empty list means none are pending. Omitted from lists, from updates, and when the pending read failed.
referral
ThreadReferralModel
DEV-1274 (contract C8): the person a recruiting reply points to when its interest is referral. Every part is copied from the reply itself; a part the reply does not give is null. The app shows it as a candidate to add (Task 24).
language
string
The ISO 639-1 code of the newest inbound message (the classifier's language, else detected from the message's own words). On GET /v1/threads an email row carries its own message's, so a row whose newest message is ours has none. Omitted when unknown.
Request
curl -X PATCH "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20" \
  -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 '{}'
Response 200
{
  "success": true,
  "data": {
    "id": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
    "channel": "email",
    "direction": "inbound",
    "campaignId": 4821,
    "contactId": 90125,
    "hasDraft": false,
    "inboxId": 5530,
    "subject": "Re: Onboarding new engineers at Northwind Labs",
    "preview": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?",
    "isRead": false,
    "isArchived": false,
    "isSaved": false,
    "interest": "interested",
    "lastMessageAt": "2026-10-13T16:22:05Z",
    "waitingSince": "2026-10-13T16:22:05Z",
    "updatedAt": "2026-10-13T16:22:05Z",
    "senderName": "Amanda Chen",
    "fromName": "Amanda Chen",
    "fromEmailAddress": "[email protected]",
    "htmlBody": "<p>Thanks Alex, this is timely. We are hiring 12 engineers this quarter.</p><p>Could we talk Thursday afternoon?</p><p>Amanda</p>",
    "tags": [
      {
        "id": 341,
        "name": "Hot lead",
        "color": "#FFD6A5"
      }
    ],
    "to": [
      {
        "name": "Alex Rivera",
        "address": "[email protected]"
      }
    ],
    "language": "en"
  }
}

Returns per-id outcomes. Requires InboxReplyAccess.

Parameters

Name
In
Type
Required
Description
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Request body

application/json, optional: object.

Response 200

data is a ThreadBulkResult.

Field
Type
Description
succeeded
array of string
failed
array of ThreadBulkFailure
Request
curl -X POST "https://api.ken.so/v1/threads/bulk-update" \
  -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 '{}'
Response 200
{
  "success": true,
  "data": {
    "succeeded": [
      "string"
    ],
    "failed": [
      {
        "id": "string",
        "error": {
          "code": "string",
          "message": "string"
        }
      }
    ]
  }
}

List messages in a thread, oldest first

GET/v1/threads/{threadId}/messagesthreads.list-messages

Try it on developer.ken.so

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Response 200

data is a list of ThreadMessageModel. Each item has these fields. The response also carries pagination.

Field
Type
Description
id
string
threadId
string
channel
string
direction
string
subject
string
body
string
extractedText
string
Inbound email only: the sender's own words, with quoted history, signature, mobile footer and sign-off removed. Omitted for outbound email, LinkedIn, and a message with no readable body. body keeps the full text.
language
string
DEV-5312, inbound messages only: the message's ISO 639-1 code, the classifier's language, else detected from its own words. Omitted when unknown and on outbound messages.
occurredAt
string (date-time)
warnings
array of ThreadMessageWarningModel
Set on POST /v1/threads/{threadId}/messages when the reply went out past a safeguard the caller confirmed, and on POST /v1/threads when the new thread went out from an inbox that has not finished warming up. Omitted otherwise.
senderName
string
fromName
string
fromEmailAddress
string
htmlBody
string
tags
array of ThreadTagModel
to
array of ThreadRecipientModel
cc
array of ThreadRecipientModel
bcc
array of ThreadRecipientModel
openCount
integer (int32)
Outbound email only: open-pixel hits excluding known proxies and crawlers. 0 when not opened or not tracked.
rawOpenCount
integer (int32)
Outbound email only: raw open-pixel hits, proxies and prefetch included. Always at least Ken.Scraper.API.Canonical.ThreadMessageModel.OpenCount.
firstOpenedAt
string (date-time)
Outbound email only: the first recorded open.
lastOpenedAt
string (date-time)
Outbound email only: the latest recorded open.
referral
ThreadReferralModel
DEV-1274 (contract C8): the person a recruiting reply points to when its interest is referral. Every part is copied from the reply itself; a part the reply does not give is null. The app shows it as a candidate to add (Task 24).
attachments
array of MessageAttachmentModel
DEV-4082, GET /v1/threads/{threadId}/messages, email only: the message's files, inline images included. Download one with GET /v1/attachments/{id}/content. Omitted when the message has none.
Request
curl -X GET "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20/messages" \
  -H "Authorization: Bearer $KEN_API_KEY" \
  -H "Ken-Version: 2026-11-02" \
  -H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID"
Response 200
{
  "success": true,
  "data": [
    {
      "id": "msg_ZW1haWw6NDAyMjg0LjJAZXhhbXBsZS5jb20",
      "threadId": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
      "channel": "email",
      "direction": "inbound",
      "subject": "Re: Onboarding new engineers at Northwind Labs",
      "body": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?\n\nAmanda\n\nOn Tue, Oct 13, 2026 at 9:04 AM Alex Rivera <[email protected]> wrote:\n> Hi Amanda, following up on this.",
      "extractedText": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?",
      "language": "en",
      "occurredAt": "2026-10-13T16:22:05Z",
      "senderName": "Amanda Chen",
      "fromName": "Amanda Chen",
      "fromEmailAddress": "[email protected]",
      "htmlBody": "<p>Thanks Alex, this is timely. We are hiring 12 engineers this quarter.</p><p>Could we talk Thursday afternoon?</p><p>Amanda</p>",
      "tags": [
        {
          "id": 341,
          "name": "Hot lead",
          "color": "#FFD6A5"
        }
      ],
      "to": [
        {
          "name": "Alex Rivera",
          "address": "[email protected]"
        }
      ]
    }
  ],
  "pagination": {
    "nextCursor": "eyJpZCI6ODE1fQ",
    "hasMore": true,
    "total": 1,
    "isTotalCapped": true
  }
}

Send a message on a thread

POST/v1/threads/{threadId}/messagesthreads.create-message

Try it on developer.ken.so

Email requires senderEmailId. An email reply goes to the thread's sender unless it names to, or replyAll: true (the sender, To and Cc, less the sending inbox); cc and bcc add recipients, each one bare email address. attachmentIds are att_ ids uploaded against this thread (its newest message), and contentType is html (default) or text. Those six fields are email only: a LinkedIn reply that names any of them is 400 validation_failed on that field and sends nothing . LinkedIn sends plain text through the conversation seat. A LinkedIn Idempotency-Key is bound to its thread and message: reusing it on another thread, or with another body once decided, returns 409 idempotency_key_reused and sends nothing. Without a key, the same text repeated in a thread more than 5 minutes later is sent again. An email Idempotency-Key (optional, at most 200 characters, unique in the workspace) is bound to its thread, inbox and body, and to its to, cc, bcc, replyAll, contentType and attachmentIds when it names any: a retry returns the reply it scheduled, or the 202 with the client approval item it was held as, and queues nothing; another request under it is 409 idempotency_key_reused; one still being queued is 409 in_progress with nextAllowedAt and Retry-After; one Kenso cannot tell was queued is 409 outcome_unknown and never queues again. A refused reply frees its key. Requires InboxReplyAccess. The send is recorded only after the provider accepts it. An email reply to a do-not-contact, blocklisted or unsubscribed contact returns 409 invalid_state with a confirmSuppressed detail whose code is the reason, and is sent only when the request repeats with confirmSuppressed: true. LinkedIn replies to a suppressed contact are always refused. An email reply is recorded with its caller: a person, an API key, or an agent (any credential sent with X-Ken-Actor: agent). An API key or agent reply from its own member's mailbox is not sent: it returns 202 with the scheduled message in status: awaiting_approval and approverMemberId, and waits in that member's GET /v1/approvals until they approve it in person ; with accounts off, or from another member's own mailbox, it is 403 forbidden. Its reply from an inbox a member paused or Ken blocked from sending is a 409 invalid_state whose detail code is inbox_paused or inbox_send_blocked, with a fix. A block carries nextAllowedAt (its end) and Retry-After; a pause has no known end and carries neither. A pause or block that starts during the undo window refuses the queued reply at send time: the scheduled message ends failed. When the workspace requires external approval for replies, an email reply is not sent: the response is 202 with statusheld_for_client_approval and the clientApprovalItemId that holds it. An email reply a teammate writes in person from a client seat's own inbox, with accounts_send on, is not sent either: it returns 202 with the scheduled message (ScheduledMessageModel) in status: awaiting_approval and approverMemberId, and waits in that client's GET /v1/approvals. Email archiveAfterSend: true archives the thread once the reply sends; a waiting reply archives only when the approved reply sends, never while it waits. An API key or agent LinkedIn reply through a seat another member connected is not sent: it returns 202 with status: awaiting_approval, channel: linkedin, the approvalId (type reply.linkedin) and approverMemberId (the seat owner), and waits in that member's GET /v1/approvals until they approve it in person, when it sends once. A retry with the same Idempotency-Key (or the same text without one) returns the same waiting reply. With accounts off for the owner, or a seat no member connected, it is 403 forbidden. A person's own reply from the dashboard, and the seat owner's own agent or API key, send as before.

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Request body

application/json, optional.

Field
Type
Required
Description
body
string
optional
subject
string
optional
senderEmailId
integer (int32)
optional
confirmSuppressed
boolean
optional
Email only: send even though the contact is do-not-contact, blocklisted or unsubscribed. Set only after the user confirmed a 409 invalid_state for a suppressed contact.
archiveAfterSend
boolean
optional
Email only: archive the thread once the reply sends. A reply waiting on a client seat's approval archives when the approved reply sends, never while it waits, and a skipped one leaves the thread as it was.
to
array of ThreadAddressRequest
optional
Email only: who the reply goes to, each one bare email address. Omitted, the reply goes to the thread's sender (to everyone on the thread with Ken.Scraper.API.Dtos.CreateThreadMessageRequest.ReplyAll). A LinkedIn thread refuses it.
cc
array of ThreadAddressRequest
optional
Email only: Cc recipients, each one bare email address. A LinkedIn thread refuses it.
bcc
array of ThreadAddressRequest
optional
Email only: Bcc recipients, each one bare email address. A LinkedIn thread refuses it.
replyAll
boolean
optional
Email only: without Ken.Scraper.API.Dtos.CreateThreadMessageRequest.To, reply to the thread's sender, To and Cc, less the sending inbox. A LinkedIn thread refuses it.
attachmentIds
array of string
optional
Email only: att_ ids of files uploaded against this thread (POST /v1/attachments with the thread's newest message as messageId). A LinkedIn thread refuses it.
contentType
string
optional
Email only: html (the default) or text. A LinkedIn thread refuses it.

Response 201

Also answers 202. data is a ThreadMessageModel.

Field
Type
Description
id
string
threadId
string
channel
string
direction
string
subject
string
body
string
extractedText
string
Inbound email only: the sender's own words, with quoted history, signature, mobile footer and sign-off removed. Omitted for outbound email, LinkedIn, and a message with no readable body. body keeps the full text.
language
string
DEV-5312, inbound messages only: the message's ISO 639-1 code, the classifier's language, else detected from its own words. Omitted when unknown and on outbound messages.
occurredAt
string (date-time)
warnings
array of ThreadMessageWarningModel
Set on POST /v1/threads/{threadId}/messages when the reply went out past a safeguard the caller confirmed, and on POST /v1/threads when the new thread went out from an inbox that has not finished warming up. Omitted otherwise.
senderName
string
fromName
string
fromEmailAddress
string
htmlBody
string
tags
array of ThreadTagModel
to
array of ThreadRecipientModel
cc
array of ThreadRecipientModel
bcc
array of ThreadRecipientModel
openCount
integer (int32)
Outbound email only: open-pixel hits excluding known proxies and crawlers. 0 when not opened or not tracked.
rawOpenCount
integer (int32)
Outbound email only: raw open-pixel hits, proxies and prefetch included. Always at least Ken.Scraper.API.Canonical.ThreadMessageModel.OpenCount.
firstOpenedAt
string (date-time)
Outbound email only: the first recorded open.
lastOpenedAt
string (date-time)
Outbound email only: the latest recorded open.
referral
ThreadReferralModel
DEV-1274 (contract C8): the person a recruiting reply points to when its interest is referral. Every part is copied from the reply itself; a part the reply does not give is null. The app shows it as a candidate to add (Task 24).
attachments
array of MessageAttachmentModel
DEV-4082, GET /v1/threads/{threadId}/messages, email only: the message's files, inline images included. Download one with GET /v1/attachments/{id}/content. Omitted when the message has none.
Request
curl -X POST "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20/messages" \
  -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 '{
  "body": "string",
  "subject": "string",
  "senderEmailId": 812,
  "confirmSuppressed": true,
  "archiveAfterSend": true,
  "to": [
    {
      "name": "string",
      "address": "string"
    }
  ],
  "cc": [
    {
      "name": "string",
      "address": "string"
    }
  ],
  "bcc": [
    {
      "name": "string",
      "address": "string"
    }
  ],
  "replyAll": true,
  "attachmentIds": [
    "att_ZW1haWw6NDAyMjg0LjJAZXhhbXBsZS5jb206MQ"
  ],
  "contentType": "string"
}'
Response 201
{
  "success": true,
  "data": {
    "id": "msg_ZW1haWw6NDAyMjg0LjJAZXhhbXBsZS5jb20",
    "threadId": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
    "channel": "email",
    "direction": "inbound",
    "subject": "Re: Onboarding new engineers at Northwind Labs",
    "body": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?\n\nAmanda\n\nOn Tue, Oct 13, 2026 at 9:04 AM Alex Rivera <[email protected]> wrote:\n> Hi Amanda, following up on this.",
    "extractedText": "Thanks Alex, this is timely. We are hiring 12 engineers this quarter. Could we talk Thursday afternoon?",
    "language": "en",
    "occurredAt": "2026-10-13T16:22:05Z",
    "senderName": "Amanda Chen",
    "fromName": "Amanda Chen",
    "fromEmailAddress": "[email protected]",
    "htmlBody": "<p>Thanks Alex, this is timely. We are hiring 12 engineers this quarter.</p><p>Could we talk Thursday afternoon?</p><p>Amanda</p>",
    "tags": [
      {
        "id": 341,
        "name": "Hot lead",
        "color": "#FFD6A5"
      }
    ],
    "to": [
      {
        "name": "Alex Rivera",
        "address": "[email protected]"
      }
    ]
  }
}

Forward an email thread to new recipients

POST/v1/threads/{threadId}/forwardthreads.forward

Try it on developer.ken.so

Requires MemberReadAccess (a white-label Client replies but does not forward). Email only. The forward is queued and the provider confirms delivery later. to is required unless replyAll is true. delaySeconds is 60 or 300. attachmentIds are att_ ids for this thread. Every recipient is one bare email address. A forward that reaches the thread (replyAll without to, or the contact or another participant as a recipient) returns 409 invalid_state when the contact is suppressed (detail code is the reason; there is no confirm override, send a reply instead) or when the workspace holds replies for external approval (detail code client_approval_required). A queued forward returns 200 with the scheduled message. An API key or agent forward from its own member's mailbox is not sent: it returns 202 with the scheduled message in status: awaiting_approval and approverMemberId, and waits in that member's GET /v1/approvals until they approve it in person; with accounts off, or from another member's own mailbox, it is 403 forbidden.

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Request body

application/json, optional.

Field
Type
Required
Description
body
string
optional
senderEmailId
integer (int32)
optional
contentType
string
optional
replyAll
boolean
optional
injectPreviousEmailBody
boolean
optional
replyTemplateId
integer (int32)
optional
useDedicatedIps
boolean
optional
to
array of ThreadAddressRequest
optional
cc
array of ThreadAddressRequest
optional
bcc
array of ThreadAddressRequest
optional
delaySeconds
integer (int32)
optional
archiveAfterSend
boolean
optional
attachmentIds
array of string
optional

Response 200

Also answers 202. data is a ScheduledMessageModel.

Field
Type
Description
id
integer (int64)
messageId
string
kind
string
status
string
scheduled, sending, sent, cancelled, failed, or awaiting_approval.
approverMemberId
integer (int64)
With status awaiting_approval: the client seat whose own inbox sends the reply and who must approve it . Names come from /v1/members.
delaySeconds
integer (int32)
sendAt
string (date-time)
createdAt
string (date-time)
body
string
senderEmailId
integer (int32)
to
array of ThreadRecipientModel
cc
array of ThreadRecipientModel
bcc
array of ThreadRecipientModel
draft
ScheduledMessageDraftModel
Request
curl -X POST "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20/forward" \
  -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 '{
  "body": "string",
  "senderEmailId": 812,
  "contentType": "string",
  "replyAll": true,
  "injectPreviousEmailBody": true,
  "replyTemplateId": 812,
  "useDedicatedIps": true,
  "to": [
    {
      "name": "string",
      "address": "string"
    }
  ],
  "cc": [
    {
      "name": "string",
      "address": "string"
    }
  ],
  "bcc": [
    {
      "name": "string",
      "address": "string"
    }
  ],
  "delaySeconds": 1,
  "archiveAfterSend": true,
  "attachmentIds": [
    "att_ZW1haWw6NDAyMjg0LjJAZXhhbXBsZS5jb206MQ"
  ]
}'
Response 200
{
  "success": true,
  "data": {
    "id": 812,
    "messageId": "msg_ZW1haWw6NDAyMjg0LjJAZXhhbXBsZS5jb20",
    "kind": "string",
    "status": "string",
    "approverMemberId": 812,
    "delaySeconds": 1,
    "sendAt": "2026-09-23T15:02:11Z",
    "createdAt": "2026-09-23T15:02:11Z",
    "body": "string",
    "senderEmailId": 812,
    "to": [
      {
        "name": "string",
        "address": "string"
      }
    ],
    "cc": [
      {
        "name": "string",
        "address": "string"
      }
    ],
    "bcc": [
      {
        "name": "string",
        "address": "string"
      }
    ],
    "draft": {
      "messageId": "msg_ZW1haWw6NDAyMjg0LjJAZXhhbXBsZS5jb20",
      "kind": "string",
      "body": "string",
      "senderEmailId": 812,
      "to": [
        {
          "name": "string",
          "address": "string"
        }
      ],
      "cc": [
        {
          "name": "string",
          "address": "string"
        }
      ],
      "bcc": [
        {
          "name": "string",
          "address": "string"
        }
      ],
      "saved": true
    }
  }
}

Return the saved draft for a thread

GET/v1/threads/{threadId}/draftthreads.get-draft

Try it on developer.ken.so

authorSource says who last wrote it: user, a person in the dashboard, or agent. The agent's draft also carries mirror, the same reply in the reader's language, when it wrote in the lead's ; otherwise it is absent.

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Response 200

data is a ThreadDraftModel.

Field
Type
Description
threadId
string
subject
string
body
string
authorSource
string
Who last wrote the draft: user, a person in the dashboard, or agent, an agent, MCP token or API key. An agent never overwrites a person's draft.
mirror
ThreadDraftMirrorModel
updatedAt
string (date-time)
Request
curl -X GET "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20/draft" \
  -H "Authorization: Bearer $KEN_API_KEY" \
  -H "Ken-Version: 2026-11-02" \
  -H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID"
Response 200
{
  "success": true,
  "data": {
    "threadId": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
    "subject": "string",
    "body": "string",
    "authorSource": "string",
    "mirror": {
      "text": "string",
      "language": "string"
    },
    "updatedAt": "2026-09-23T15:02:11Z"
  }
}

Replace the draft for a thread

PUT/v1/threads/{threadId}/draftthreads.replace-draft

Try it on developer.ken.so

An empty body deletes it.

Only a person in a dashboard session writes as the draft's author. An agent (X-Ken-Actor: agent), an MCP token or an API key writes as agent: it never overwrites or empty-deletes a person's draft, and the response is the person's draft unchanged.

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Request body

application/json, optional.

Field
Type
Required
Description
subject
string
optional
body
string
optional

Response 200

data is a ThreadDraftModel.

Field
Type
Description
threadId
string
subject
string
body
string
authorSource
string
Who last wrote the draft: user, a person in the dashboard, or agent, an agent, MCP token or API key. An agent never overwrites a person's draft.
mirror
ThreadDraftMirrorModel
updatedAt
string (date-time)
Request
curl -X PUT "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20/draft" \
  -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 '{
  "subject": "string",
  "body": "string"
}'
Response 200
{
  "success": true,
  "data": {
    "threadId": "thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20",
    "subject": "string",
    "body": "string",
    "authorSource": "string",
    "mirror": {
      "text": "string",
      "language": "string"
    },
    "updatedAt": "2026-09-23T15:02:11Z"
  }
}

Delete the draft for a thread

DELETE/v1/threads/{threadId}/draftthreads.delete-draft

Try it on developer.ken.so

Parameters

Name
In
Type
Required
Description
threadId
In: path
string
required
Ken-Version
In: header
string, one of: 2026-11-02, 1.1
required
The API version: a dated version (YYYY-MM-DD), or 1.1, another name for the first dated version. Without it the request uses your API key's pinned version; an unpinned key reaches the legacy shape of the same path. 1.0 selects the legacy shape explicitly.
X-Ken-Workspace-Id
In: header
integer (int64)
optional
Workspace to act in. Required when your credential can reach more than one workspace (otherwise 400 workspace_required).

Response 204

Request
curl -X DELETE "https://api.ken.so/v1/threads/thr_ZW1haWw6NDAyMjg0LjFAZXhhbXBsZS5jb20/draft" \
  -H "Authorization: Bearer $KEN_API_KEY" \
  -H "Ken-Version: 2026-11-02" \
  -H "X-Ken-Workspace-Id: $KEN_WORKSPACE_ID"
Response 204
HTTP/1.1 204 No Content