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. API tokens also get their project's own types (ext.*).

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.

Register an event type

POST/event-types

Registers one of the project's own event types, ext.<thing>.<past-tense verb>, or updates it compatibly: within a version, data fields may only be added, and only as optional. Anything else needs a new version.

For API tokens with events:write.

Request body (JSON)

FieldTypeDescription
description requiredstring
name requiredstringPattern: ^ext\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$.
dataobject[]
versioninteger
{"data":[{"description":"The shop's order ID.","name":"orderId","required":true,"type":"string"}],"description":"An order left the warehouse.","name":"ext.order.shipped"}

Response body (JSON)

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.

Responses

StatusBodyMeaning
201EventTypeThe registered type.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks events:write.
422ErrorInvalid, or an incompatible change within a version.

Read the project's events

GET/events

Every event of the project, oldest first, across its inboxes and the project itself: a page after a cursor, or with Accept: text/event-stream a live stream that resumes from Last-Event-ID. Each event's id: line in the stream is its cursor. You see inbox events for inboxes you may use; project-level events (access, domains, sending) need project:read; audit-only events (who read what) need audit:read and audit=true. Paged reads return events a few seconds old, so none arrive later behind the cursor; live streams send at once and may repeat an event after reconnecting, so dedupe by event id. The web app must name its inboxes and sees read state without who read it.

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

Parameters

NameTypeDescription
after (query)stringCursor: return events after it (the cursor of the previous page, or an event's stream id).
types (query)stringComma-separated types or prefixes ending in .*, e.g. message.*,ext.order.shipped.
inboxes (query)stringComma-separated inbox IDs: only their events (and no project-level ones).
audit (query)booleantrue includes audit-only events (needs audit:read).
limit (query)integerPage size, up to 500.
Last-Event-ID (header)stringLive streams: resume after this cursor.

Response body (JSON)

FieldTypeDescription
cursor requiredstringPass as after= for the next page.
events requiredEvent[]

Responses

StatusBodyMeaning
200JSON, text/event-streamA page, or the live stream.
400ErrorThe web app must name inboxes.
401ErrorNo token, or it expired: sign in again.
403ErrorMissing scope (events:read, or audit:read for audit=true).
404ErrorNot found, or not yours.

Publish an event

POST/events

Publishes one event of a type the project registered. Events are plaintext and kept until the project is deleted, so keep them thin: IDs and small facts, never message content or secrets (data up to 4 KB). Send Idempotency-Key to make retries safe: the same key within 24 hours returns the first event.

For API tokens with events:write.

Parameters

NameTypeDescription
Idempotency-Key (header)stringUp to 255 characters; the same key returns the first event.

Request body (JSON)

FieldTypeDescription
type requiredstringA registered ext.* type.
dataobjectFields as the type defines them; up to 4 KB.
inboxstringAn inbox it concerns (ibx_…), if any.
subjectstringWhat the event is about, in your terms (e.g. an order number).
{"data":{"orderId":"1001"},"subject":"order 1001","type":"ext.order.shipped"}

Response body (JSON)

FieldTypeDescription
duplicatetrue
idstring

Responses

StatusBodyMeaning
200JSONA retry with the same Idempotency-Key: the first event.
201JSONPublished.
401ErrorNo token, or it expired: sign in again.
403ErrorThe token lacks events:write.
404ErrorNot found, or not yours.
413Errordata is over 4 KB.
422ErrorUnregistered type, or data that doesn't match it.

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

Event

A CloudEvents 1.0 event with K8 HQ's attributes.

FieldTypeDescription
actor requiredstringWho acted: a member (mem_), a token (tok_) or K8 HQ (sys_…). Empty when removed for privacy.
class requiredstring
id requiredstring
scope requiredstringThe project (prj_).
sequence requiredstringIts position in its own stream (the inbox, or the project).
source requiredstring
time requiredstring
type requiredstring
visibility requiredstring
causationidstring
containeridstringThe inbox it concerns, if any.
correlationidstring
dataobject
dataschemastring
subjectstring

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

NewEventType

FieldTypeDescription
description requiredstring
name requiredstringPattern: ^ext\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$.
dataobject[]
versioninteger

WebFlag

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