K8 HQ Content API

Version 1 · OpenAPI spec (JSON)

Two ways in, one API:

- **API tokens** (scripts, AI agents, integrations) use https://api.k8hq.com/v1 with Authorization: Bearer k8hq_.... A token belongs to one project, acts for a member with that member's current access, can be narrowed to some inboxes, and has scopes: messages:read, content:read, flags:write, send. K8 HQ staff issue tokens for now.

- **The K8 HQ web app** uses https://app.k8hq.com/api/v1 with a session from signing in with a passkey.

Message content is encrypted at rest. Metadata (sender, subject, flags) comes back in plain JSON. With content:read, a token can read a message decrypted (/content, /source, /attachments/{n}) from inboxes in compatible mode, where K8 HQ's servers can open them; each such read is recorded in the inbox's audit log. Zero-access inboxes, and the web app, get ciphertext only (/raw), opened with keys sealed to the user's own key (/keys).

Responses that never change (/raw) may be kept by the browser; message metadata carries an ETag to revalidate. Shared caches never store responses.

Errors are JSON objects with one field, error. Inboxes you have no access to, and messages or labels that aren't in the inbox, return 404.

Authentication

Base URLFor
https://api.k8hq.com/v1API tokens
https://app.k8hq.com/api/v1The K8 HQ web app (sessions)
curl -H "Authorization: Bearer $K8HQ_TOKEN" https://api.k8hq.com/v1/inboxes

Endpoints

Inboxes

List your inboxes

GET/inboxes

The inboxes you've been granted access to, with your role in each.

For API tokens with messages:read and the web app.

Response body (JSON)

FieldTypeDescription
inboxes requiredInboxGrant[]

Responses

StatusBodyMeaning
200JSONYour inboxes.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.

Get the inbox's keys

GET/inboxes/{inbox}/keys

Every epoch key of the inbox, each sealed to your user key, which you set up in the web app. Messages name the epoch they were encrypted under (keyEpoch).

For API tokens with messages:read and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).

Response body (JSON)

FieldTypeDescription
epochs requiredobject[]

Responses

StatusBodyMeaning
200JSONThe sealed epoch keys.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.
409ErrorYou haven't set up your encryption key yet.

List labels

GET/inboxes/{inbox}/labels

The inbox's labels (folders in a mail app): INBOX first, then special-use labels (Sent, Drafts, Trash, Spam, Archive), then the rest by name. A message can carry several labels.

For API tokens with messages:read and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).

Response body (JSON)

FieldTypeDescription
labels requiredLabel[]

Responses

StatusBodyMeaning
200JSONThe labels.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Messages

List a label's messages

GET/inboxes/{inbox}/labels/{label}/messages

Newest first. To get the next page, pass the response's before back as before; it's absent on the last page.

For API tokens with messages:read and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).
label (path) requiredstringLabel ID (lbl_…).
limit (query)integerPage size, 1 to 100.
before (query)integerOnly messages with a lower UID than this (a cursor from the previous page).

Response body (JSON)

FieldTypeDescription
messages requiredMessageSummary[]
beforeintegerCursor for the next page.

Responses

StatusBodyMeaning
200JSONOne page of messages.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Get a message

GET/inboxes/{inbox}/messages/{msg}

Metadata plus the message's data key, wrapped to the inbox epoch key. The body comes from /raw. The response carries an ETag (it changes with any change to the message, including data added later) and Cache-Control: private, no-cache: send If-None-Match to get 304 when nothing changed.

For API tokens with messages:read and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).
msg (path) requiredstringMessage ID (msg_…).
If-None-Match (header)stringThe ETag of a copy you have.

Response body (JSON)

FieldTypeDescription
keyEpoch requiredintegerThe inbox epoch the message was encrypted under.
message requiredMessageSummary
wrappedDek requiredstringThe message's data key, sealed to the epoch key.
ccstring
inReplyTostring
messageIdstringThe Message-ID header.
replyTostring

Responses

StatusBodyMeaning
200JSONThe message.
304Not modified: your copy is current.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Change a message's flags

POST/inboxes/{inbox}/messages/{msg}/flags

Adds and removes flags by their neutral names. Mail apps connected over IMAP, and other open web app tabs, see the change right away. Other flags (draft, junk, ...) are set by mail apps.

For API tokens with flags:write and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).
msg (path) requiredstringMessage ID (msg_…).

Request body (JSON)

FieldTypeDescription
addWebFlag[]
removeWebFlag[]
{"add":["seen"]}

Response body (JSON)

FieldTypeDescription
flags requiredFlag[]

Responses

StatusBodyMeaning
200JSONThe message's flags after the change.
400ErrorThe request is malformed.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Get a message's encrypted content

GET/inboxes/{inbox}/messages/{msg}/raw

The encrypted RFC 5322 message exactly as stored. Decrypt it with the data key from GET /inboxes/{inbox}/messages/{msg}. Messages never change, so the response may be kept by the browser indefinitely (Cache-Control: private, max-age=31536000, immutable).

For API tokens with messages:read and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).
msg (path) requiredstringMessage ID (msg_…).

Responses

StatusBodyMeaning
200application/octet-streamCiphertext.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Content

Get an attachment (decrypted)

GET/inboxes/{inbox}/messages/{msg}/attachments/{n}

One attachment or inline part, with its content type and filename. Compatible-mode inboxes only; audited. Never cached.

For API tokens with content:read.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).
msg (path) requiredstringMessage ID (msg_…).
n (path) requiredintegerThe attachment's index from /content.

Responses

StatusBodyMeaning
200application/octet-streamThe attachment.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Read a message (decrypted)

GET/inboxes/{inbox}/messages/{msg}/content

The message decrypted and parsed: headers, the text and HTML bodies, and its attachments and inline parts (fetch each by index). Compatible-mode inboxes only; the read is recorded in the inbox's audit log (access.content.read). Never cached.

For API tokens with content:read.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).
msg (path) requiredstringMessage ID (msg_…).

Response body (JSON)

FieldTypeDescription
attachments requiredAttachment[]
flags requiredFlag[]
headers requiredobjectDecoded headers: from, to, cc, replyTo, subject, date, messageId, inReplyTo, references (those present).
html requiredstringThe HTML body (empty if none). Untrusted: sanitize before display.
id requiredstring
receivedAt requiredstring
text requiredstringThe plain-text body (empty if none).
keywordsstring[]

Responses

StatusBodyMeaning
200JSONThe message.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.
422ErrorThe message couldn't be parsed; read it whole from /source.

Get a message's source (decrypted)

GET/inboxes/{inbox}/messages/{msg}/source

The whole RFC 5322 message, decrypted. Compatible-mode inboxes only; audited like /content. Never cached.

For API tokens with content:read.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).
msg (path) requiredstringMessage ID (msg_…).

Responses

StatusBodyMeaning
200message/rfc822The message.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Sending

Get what's needed to send

GET/inboxes/{inbox}/compose

The addresses the inbox receives on and the current epoch public key to seal outgoing messages to.

For API tokens with send and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).

Response body (JSON)

FieldTypeDescription
addresses requiredstring[]
epoch requiredinteger
epochPublic requiredstringThe epoch's X25519 public key.

Responses

StatusBodyMeaning
200JSONAddresses and the sealing key.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Send a message (plaintext)

POST/inboxes/{inbox}/messages

Sends an RFC 5322 message as given: the same checks and limits as sending from a mail app, and a copy is filed in Sent. For API tokens; the web app seals its messages (/send). Up to 100 recipients and 25 MB.

For API tokens with send.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).

Request body (JSON)

FieldTypeDescription
from requiredstringA bare address the inbox may send as; it must match the message's From header.
raw requiredstringThe RFC 5322 message.
to requiredstring[]Every recipient (To, Cc and Bcc), as bare addresses.

Response body (JSON)

FieldTypeDescription
sent requiredtrue

Responses

StatusBodyMeaning
200JSONAccepted for delivery.
400ErrorThe request is malformed.
401ErrorNo token, or it expired: sign in again.
403ErrorThe inbox can't send as from.
404ErrorNot found, or not yours.
422ErrorRefused: a sending limit or policy, with the reason.
503ErrorTemporary failure; try again later.

Send a message

POST/inboxes/{inbox}/send

Sends a message the client sealed to the inbox's epoch key, bound to a draft ID. It goes through the same checks and limits as sending from a mail app, and a copy is filed in Sent. Up to 100 recipients and 25 MB.

For API tokens with send and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).

Request body (JSON)

FieldTypeDescription
ciphertext requiredstringThe RFC 5322 message, encrypted with the data key.
draftId requiredstringA new random ID per message; the ciphertext is bound to it. Pattern: ^draft_[A-Za-z0-9_-]{16,64}$.
epoch requiredintegerThe epoch from /compose the message was sealed to.
from requiredstringA bare address the inbox may send as; it must match the message's From header.
to requiredstring[]Every recipient (To, Cc and Bcc), as bare addresses.
wrappedDek requiredstring

Response body (JSON)

FieldTypeDescription
sent requiredtrue

Responses

StatusBodyMeaning
200JSONAccepted for delivery.
400ErrorThe request is malformed.
401ErrorNo token, or it expired: sign in again.
403ErrorThe inbox can't send as from.
404ErrorNot found, or not yours.
422ErrorRefused: a sending limit or policy, with the reason.
503ErrorTemporary failure; try again later.

Events

List event types

GET/event-types

The event catalog: every event type K8 HQ records, with its JSON Schema. Audit-only types are listed with their visibility; they can't trigger rules.

For API tokens and the web app.

Response body (JSON)

FieldTypeDescription
eventTypes requiredEventType[]

Responses

StatusBodyMeaning
200JSONThe catalog.
401ErrorNo token, or it expired: sign in again.

Stream inbox changes

GET/inboxes/{inbox}/events

Server-Sent Events announcing changes to the inbox, so a client can update its lists without polling. Each event carries IDs only; fetch details through the API. Event names are message.added, message.removed and message.flags, and each event's data is a LiveChange JSON object. The server sends a comment every 15 seconds to keep the connection open and ends the stream after 10 minutes, so reconnect with a fresh token.

For API tokens with messages:read and the web app.

Parameters

NameTypeDescription
inbox (path) requiredstringInbox ID (ibx_…).

Responses

StatusBodyMeaning
200text/event-streamAn event stream.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session.
404ErrorNot found, or not yours.

Schemas

Attachment

FieldTypeDescription
contentType requiredstring
index requiredinteger
inline requiredboolean
size requiredintegerBytes, decoded.
contentIdstringFor inline parts referenced from the HTML (cid:).
filenamestring

Error

FieldTypeDescription
error requiredstring

EventType

FieldTypeDescription
class requiredstring
data requiredobject[]The data fields.
dataSchema requiredobjectJSON Schema for the event's data.
description requiredstring
name requiredstring
source requiredstring
status requiredexperimental | stable | deprecated
version requiredinteger
visibility requiredstring
subjectstringWhat the event's subject holds.

Flag

A message flag, by its neutral name: the same vocabulary as events, whatever the channel. Mail apps' IMAP flags map onto it (\Seen is seen, $Junk is junk). Values: seen, answered, flagged, draft, forwarded, junk, not_junk.

InboxGrant

FieldTypeDescription
inboxId requiredstring
role requiredstringYour role in the inbox, e.g. owner.
labelstringDisplay name, usually the inbox's main address.

Label

FieldTypeDescription
id requiredstring
name requiredstring
total requiredinteger
unread requiredinteger
specialUse\Sent | \Drafts | \Trash | \Junk | \ArchiveThe label's role, as IMAP names it.

LiveChange

FieldTypeDescription
msgId requiredstring
type requiredmessage.added | message.removed | message.flags
flagsFlag[]The message's flags now (message.flags).
labelIdstringThe label a message was added to or removed from.
uidintegerThe message's number in the label (message.added).

MessageSummary

FieldTypeDescription
flags requiredFlag[]The message's flags, by neutral name (the same names events use).
id requiredstring
receivedAt requiredstring
size requiredintegerBytes.
uid requiredintegerThe message's number in this label (0 when fetched outside a label).
datestringThe Date header.
directionin | out
fromstring
keywordsstring[]Custom keywords mail apps have set, e.g. $label1.
subjectstring
tostring

WebFlag

The flags the API can change. Values: seen, flagged, answered.