Threads
Inbox threads across email and LinkedIn: count and list them, read messages, reply, forward, update in bulk and keep drafts.
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
400 workspace_required).Response 200
data is a ThreadCountsModel.
GET /v1/threads lists them with the same tab filters.threadCount is 0 when none is in the requested tab.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"
{
"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
/v1/threads/mark-all-readthreads.mark-all-readRequires 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
400 workspace_required).Response 200
data is a MarkAllThreadsReadModel.
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"
{
"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
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.400 workspace_required).Response 200
data is a list of ThreadModel. Each item has these fields. The response also carries pagination.
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.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.GET /v1/threads/{threadId}/draft returns it. On lists, threads.get and updates. False when the draft read failed.user (a person in the dashboard) or agent (an agent, MCP token or API key). Absent without a draft.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 .fromName.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. 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).GET /v1/threads an email row carries its own message's, so a row whose newest message is ours has none. Omitted when unknown.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"
{
"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
/v1/threadsthreads.createNeeds the agent_send feature on your workspace. Until it is on, calls answer 404.
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
400 workspace_required).Request body
application/json, optional.
GET /v1/inboxes). Never a member's own mailbox.Response 201
data is a ThreadMessageModel.
body keeps the full text.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.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).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.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"
}'{
"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]"
}
]
}
}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
400 workspace_required).Response 200
data is a ThreadModel.
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.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.GET /v1/threads/{threadId}/draft returns it. On lists, threads.get and updates. False when the draft read failed.user (a person in the dashboard) or agent (an agent, MCP token or API key). Absent without a draft.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 .fromName.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. 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).GET /v1/threads an email row carries its own message's, so a row whose newest message is ours has none. Omitted when unknown.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"
{
"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"
}
}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
400 workspace_required).Request body
application/json, optional: object.
Response 200
data is a ThreadModel.
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.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.GET /v1/threads/{threadId}/draft returns it. On lists, threads.get and updates. False when the draft read failed.user (a person in the dashboard) or agent (an agent, MCP token or API key). Absent without a draft.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 .fromName.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. 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).GET /v1/threads an email row carries its own message's, so a row whose newest message is ours has none. Omitted when unknown.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 '{}'{
"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"
}
}Apply the same field update to up to 1,000 threads
/v1/threads/bulk-updatethreads.bulk-updateReturns per-id outcomes. Requires InboxReplyAccess.
Parameters
400 workspace_required).Request body
application/json, optional: object.
Response 200
data is a ThreadBulkResult.
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 '{}'{
"success": true,
"data": {
"succeeded": [
"string"
],
"failed": [
{
"id": "string",
"error": {
"code": "string",
"message": "string"
}
}
]
}
}List messages in a thread, oldest first
/v1/threads/{threadId}/messagesthreads.list-messagesParameters
400 workspace_required).Response 200
data is a list of ThreadMessageModel. Each item has these fields. The response also carries pagination.
body keeps the full text.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.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).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.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"
{
"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
/v1/threads/{threadId}/messagesthreads.create-messageEmail 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
400 workspace_required).Request body
application/json, optional.
409 invalid_state for a suppressed contact.att_ ids of files uploaded against this thread (POST /v1/attachments with the thread's newest message as messageId). A LinkedIn thread refuses it.html (the default) or text. A LinkedIn thread refuses it.Response 201
Also answers 202. data is a ThreadMessageModel.
body keeps the full text.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.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).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.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"
}'{
"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
/v1/threads/{threadId}/forwardthreads.forwardRequires 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
400 workspace_required).Request body
application/json, optional.
Response 200
Also answers 202. data is a ScheduledMessageModel.
/v1/members.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"
]
}'{
"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
/v1/threads/{threadId}/draftthreads.get-draftauthorSource 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
400 workspace_required).Response 200
data is a ThreadDraftModel.
user, a person in the dashboard, or agent, an agent, MCP token or API key. An agent never overwrites a person's draft.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"
{
"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
/v1/threads/{threadId}/draftthreads.replace-draftAn 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
400 workspace_required).Request body
application/json, optional.
Response 200
data is a ThreadDraftModel.
user, a person in the dashboard, or agent, an agent, MCP token or API key. An agent never overwrites a person's draft.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"
}'{
"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
/v1/threads/{threadId}/draftthreads.delete-draftParameters
400 workspace_required).Response 204
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"
HTTP/1.1 204 No Content