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 URL | For |
https://api.k8hq.com/v1 | API tokens |
https://app.k8hq.com/api/v1 | The 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)
| Field | Type | Description |
inboxes required | InboxGrant[] | |
Responses
| Status | Body | Meaning |
| 200 | JSON | Your inboxes. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
Response body (JSON)
| Field | Type | Description |
epochs required | object[] | |
Responses
| Status | Body | Meaning |
| 200 | JSON | The sealed epoch keys. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not found, or not yours. |
| 409 | Error | You 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
Response body (JSON)
| Field | Type | Description |
labels required | Label[] | |
Responses
| Status | Body | Meaning |
| 200 | JSON | The labels. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
label (path) required | string | Label ID (lbl_…). |
limit (query) | integer | Page size, 1 to 100. |
before (query) | integer | Only messages with a lower UID than this (a cursor from the previous page). |
Response body (JSON)
| Field | Type | Description |
messages required | MessageSummary[] | |
before | integer | Cursor for the next page. |
Responses
| Status | Body | Meaning |
| 200 | JSON | One page of messages. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
msg (path) required | string | Message ID (msg_…). |
If-None-Match (header) | string | The ETag of a copy you have. |
Response body (JSON)
| Field | Type | Description |
keyEpoch required | integer | The inbox epoch the message was encrypted under. |
message required | MessageSummary | |
wrappedDek required | string | The message's data key, sealed to the epoch key. |
cc | string | |
inReplyTo | string | |
messageId | string | The Message-ID header. |
replyTo | string | |
Responses
| Status | Body | Meaning |
| 200 | JSON | The message. |
| 304 | | Not modified: your copy is current. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
msg (path) required | string | Message ID (msg_…). |
Request body (JSON)
| Field | Type | Description |
add | WebFlag[] | |
remove | WebFlag[] | |
{"add":["seen"]}
Response body (JSON)
| Field | Type | Description |
flags required | Flag[] | |
Responses
| Status | Body | Meaning |
| 200 | JSON | The message's flags after the change. |
| 400 | Error | The request is malformed. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
msg (path) required | string | Message ID (msg_…). |
Responses
| Status | Body | Meaning |
| 200 | application/octet-stream | Ciphertext. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
msg (path) required | string | Message ID (msg_…). |
n (path) required | integer | The attachment's index from /content. |
Responses
| Status | Body | Meaning |
| 200 | application/octet-stream | The attachment. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
msg (path) required | string | Message ID (msg_…). |
Response body (JSON)
| Field | Type | Description |
attachments required | Attachment[] | |
flags required | Flag[] | |
headers required | object | Decoded headers: from, to, cc, replyTo, subject, date, messageId, inReplyTo, references (those present). |
html required | string | The HTML body (empty if none). Untrusted: sanitize before display. |
id required | string | |
receivedAt required | string | |
text required | string | The plain-text body (empty if none). |
keywords | string[] | |
Responses
| Status | Body | Meaning |
| 200 | JSON | The message. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not found, or not yours. |
| 422 | Error | The 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
msg (path) required | string | Message ID (msg_…). |
Responses
| Status | Body | Meaning |
| 200 | message/rfc822 | The message. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
Response body (JSON)
| Field | Type | Description |
addresses required | string[] | |
epoch required | integer | |
epochPublic required | string | The epoch's X25519 public key. |
Responses
| Status | Body | Meaning |
| 200 | JSON | Addresses and the sealing key. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
Request body (JSON)
| Field | Type | Description |
from required | string | A bare address the inbox may send as; it must match the message's From header. |
raw required | string | The RFC 5322 message. |
to required | string[] | Every recipient (To, Cc and Bcc), as bare addresses. |
Response body (JSON)
| Field | Type | Description |
sent required | true | |
Responses
| Status | Body | Meaning |
| 200 | JSON | Accepted for delivery. |
| 400 | Error | The request is malformed. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The inbox can't send as from. |
| 404 | Error | Not found, or not yours. |
| 422 | Error | Refused: a sending limit or policy, with the reason. |
| 503 | Error | Temporary 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
Request body (JSON)
| Field | Type | Description |
ciphertext required | string | The RFC 5322 message, encrypted with the data key. |
draftId required | string | A new random ID per message; the ciphertext is bound to it. Pattern: ^draft_[A-Za-z0-9_-]{16,64}$. |
epoch required | integer | The epoch from /compose the message was sealed to. |
from required | string | A bare address the inbox may send as; it must match the message's From header. |
to required | string[] | Every recipient (To, Cc and Bcc), as bare addresses. |
wrappedDek required | string | |
Response body (JSON)
| Field | Type | Description |
sent required | true | |
Responses
| Status | Body | Meaning |
| 200 | JSON | Accepted for delivery. |
| 400 | Error | The request is malformed. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The inbox can't send as from. |
| 404 | Error | Not found, or not yours. |
| 422 | Error | Refused: a sending limit or policy, with the reason. |
| 503 | Error | Temporary 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)
| Field | Type | Description |
eventTypes required | EventType[] | |
Responses
| Status | Body | Meaning |
| 200 | JSON | The catalog. |
| 401 | Error | No 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
| Name | Type | Description |
inbox (path) required | string | Inbox ID (ibx_…). |
Responses
| Status | Body | Meaning |
| 200 | text/event-stream | An event stream. |
| 401 | Error | No token, or it expired: sign in again. |
| 403 | Error | The token lacks the scope, or (decrypted content) the inbox is zero-access, or the caller is a web session. |
| 404 | Error | Not found, or not yours. |
Schemas
Attachment
| Field | Type | Description |
contentType required | string | |
index required | integer | |
inline required | boolean | |
size required | integer | Bytes, decoded. |
contentId | string | For inline parts referenced from the HTML (cid:). |
filename | string | |
Error
| Field | Type | Description |
error required | string | |
EventType
| Field | Type | Description |
class required | string | |
data required | object[] | The data fields. |
dataSchema required | object | JSON Schema for the event's data. |
description required | string | |
name required | string | |
source required | string | |
status required | experimental | stable | deprecated | |
version required | integer | |
visibility required | string | |
subject | string | What 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
| Field | Type | Description |
inboxId required | string | |
role required | string | Your role in the inbox, e.g. owner. |
label | string | Display name, usually the inbox's main address. |
Label
| Field | Type | Description |
id required | string | |
name required | string | |
total required | integer | |
unread required | integer | |
specialUse | \Sent | \Drafts | \Trash | \Junk | \Archive | The label's role, as IMAP names it. |
LiveChange
| Field | Type | Description |
msgId required | string | |
type required | message.added | message.removed | message.flags | |
flags | Flag[] | The message's flags now (message.flags). |
labelId | string | The label a message was added to or removed from. |
uid | integer | The message's number in the label (message.added). |
MessageSummary
| Field | Type | Description |
flags required | Flag[] | The message's flags, by neutral name (the same names events use). |
id required | string | |
receivedAt required | string | |
size required | integer | Bytes. |
uid required | integer | The message's number in this label (0 when fetched outside a label). |
date | string | The Date header. |
direction | in | out | |
from | string | |
keywords | string[] | Custom keywords mail apps have set, e.g. $label1. |
subject | string | |
to | string | |
WebFlag
The flags the API can change. Values: seen, flagged, answered.