Korodevelopers
SDKDashboard

Koro API Reference

The Koro API lets you build bots, integrations, and server-to-server automations on top of Koro — a secure, end-to-end encrypted messaging and collaboration platform. This document is the reference for the public, API-key-authenticated surface.

Base URL: https://api.koro.chat:3001 All endpoints below are relative to this base. Every request and response body is JSON (Content-Type: application/json), unless noted otherwise (raw binary upload endpoints are the only exception).

Table of contents

  1. Introduction
  2. Authentication
  3. Bots & receiving messages
  4. Rate limits & errors
  5. Resource reference
  1. Webhooks deep-dive
  2. SDK
  3. Versioning & changelog

1. Introduction

Koro is an end-to-end encrypted (E2E) chat and collaboration platform. The API gives third-party developers programmatic access to the same primitives the Koro apps use: conversations, messages, spaces (workspaces), meetings, tasks, calendars, reminders, media, and webhooks.

The E2E model in one paragraph. Koro never holds the plaintext of a message. Every message is encrypted on the sender's device, once per recipient device, using NaCl public-key cryptography (crypto_box). The server stores only opaque ciphertext blobs addressed to individual devices — it cannot read message content, and neither can anyone with access to the database. This has a direct consequence for the API: when you send a message you do not send plaintext. You send a list of per-device { device_id, ciphertext, nonce } envelopes that you produced by encrypting to each recipient's public key. The koro-sdk npm package implements all of this crypto for you; you can also do it yourself with the device list returned by GET /conversations/:id/devices.

System-generated content (inbound-webhook bot posts, group "X joined" notices) is the one exception: it travels as a kind: "system" envelope whose payload is plaintext JSON, because no human device pre-shared a key with "the system."


2. Authentication

All API requests authenticate with a workspace-scoped API key passed as a Bearer token:

Authorization: Bearer koro_live_<prefix>_<secret>

Getting an API key

  1. Open the Koro Developer Portal and select the workspace (Space) the key

should act on behalf of.

  1. Go to Settings → API keys → Create key.
  2. Give it a label, pick the scopes it needs, and (optionally) bind it

to a bot device and/or set an expiry.

  1. Copy the secret. It is shown exactly once and is never retrievable again —

store it in your server's secret manager.

Programmatically, a workspace owner/admin can mint keys via POST /workspaces/:id/api-keys.

Key format

koro_live_<prefix>_<secret>
└────────┘ └──────┘ └──────┘
  fixed     8 chars  ~43 chars

auth layer for sandbox use.)

portal; the full key is SHA-256 hashed server-side. Koro never stores your raw secret.

Scopes

Each key carries an array of scopes. A request to an endpoint missing the required scope returns 403 Missing scope: <scope>. The wildcard scope * grants everything.

ScopeGrants
conversations:readList/read conversations, members, devices
conversations:writeCreate conversations, manage members/roles/settings
messages:readList messages, reactions, pins
messages:writeSend/edit/delete messages, react, pin, schedule
tasks:readList tasks, lists, timers
tasks:writeCreate/update/delete tasks, lists, start/stop timers
users:readLook up users, search, profiles
webhooks:manageRegister/list/delete outbound + inbound webhooks
*All of the above

Security notes

one.

DELETE /api-keys/:id.

apps, public repos). API keys are server-side credentials. They grant access to an entire workspace within their scopes.

rejected with 401.


3. Bots & receiving messages

A bot on Koro is a real, named identity in a workspace — with its own display name, optional @username, and avatar — that appears in chats just like a person. Under the hood it is the combination of:

  1. A bot user + device — a real users row (is_bot: true) plus an

api_bot device that holds the bot's NaCl key pair. The display name/avatar you give the bot are what other members see as the message sender.

  1. An API key bound to that bot device (crm_device_id). Authenticating a

request with this key makes the request act as the bot: messages it sends are stamped with the bot's identity. The same key is used as the Authorization header for every call.

Creating a named bot

Create bots in the Koro Developer Portal (Bots → Bot erstellen) — the fastest path — or via the API:

POST /workspaces/:id/bots

Required scope: workspace owner/admin (JWT, from the portal session). Body:

{
  "name": "Support-Bot",
  "username": "support-bot",
  "avatar_url": "https://…/avatar.png",
  "identity_public_key": "<base64 Curve25519 box public key>"
}

The identity_public_key is the public half of a key pair you generate yourself (the portal does this in your browser with tweetnacl). The secret half never leaves your machine — that is what keeps the bot end-to-end encrypted.

Response (the API-key secret is shown once):

{
  "bot": {
    "id": "…", "user_id": "…", "device_id": "…",
    "user": { "display_name": "Support-Bot", "username": "support-bot", "is_bot": true },
    "api_key": { "key_prefix": "koro_live_ab12", "scopes": ["messages:read","messages:write","conversations:read","conversations:write"] }
  },
  "secret": "koro_live_ab12_<…>",
  "fingerprint": "A9F2 C31B …"
}

Store the returned secret as KORO_TOKEN and your generated secret key as KORO_BOT_SECRET (the koro-sdk reads both from .env). Then add the bot to a conversation (POST /conversations/:id/members, or Bot → in Konversation einladen in the portal) so it can send and receive there.

Other bot endpoints (owner/admin):

How a bot sends a message (E2E)

Because Koro is end-to-end encrypted, sending is a three-step dance:

  1. Fetch recipient devices for the conversation —

GET /conversations/:id/devices. You get every active member device with its identity_public_key.

  1. Encrypt your plaintext once per device with NaCl box, producing a

{ device_id, ciphertext, nonce } triple per recipient.

  1. POST the fanout to POST /messages.

The koro-sdk collapses all three into koro.messages.send({ conversationId, text }).

How a bot receives messages

Koro offers two delivery models. You can use either or both:

GET /conversations/:id/messages on an interval. Each message includes the ciphertext/nonce addressed to your bot device, which you decrypt locally.

(POST /webhooks) for events like message.new. Koro POSTs event metadata to your URL the moment a message is sent. Note that, by design, webhook payloads carry envelope metadata only (sender, conversation, timestamps) — not plaintext, since the server holds no decryption key. Use the webhook as a trigger, then fetch + decrypt the message via the API.

For chat-ops style integrations (GitHub → channel, Sentry → channel) where you just want to post into a conversation without managing crypto, use inbound webhooks: Koro renders your JSON payload as a plaintext system message in the bound conversation.

See koro-sdk for a ready-made bot loop (koro.bot.onMessage(...)).

4. Rate limits & errors

Error envelope

Any non-2xx response carries a JSON body with an error string:

{ "error": "Missing scope: messages:write" }

Status codes

StatusMeaning
200OK
201Created
400Bad request — malformed JSON or missing/invalid fields
401Unauthorized — missing, malformed, expired, or revoked API key
403Forbidden — valid key but missing scope, or not permitted on this object
404Not found
429Too Many Requests — rate limit exceeded
503Service unavailable — feature not configured (e.g. AI), or draining

Rate limits

API-key requests are rate-limited per key, per scope. The default budget is ~300 requests per minute per scope (configurable per deployment). When you exceed it you receive 429. Back off and retry.

Retry guidance. Treat 429 and 5xx as transient — retry with exponential backoff (e.g. 1s, 2s, 4s, …, capped). Do not retry 400/401/403/404; fix the request first.


5. Resource reference

Throughout this section, :id-style path segments are placeholders for UUIDs unless stated otherwise. Identifiers in examples are illustrative.

5.1 Messages

Messages are end-to-end encrypted. The server stores one opaque ciphertext copy per recipient device. See §3 for the encryption flow.

POST /messages

Send a message (E2E fanout). Scope: messages:write.

You must supply one recipients[] entry per active recipient device. Get the device list (with public keys) from GET /conversations/:id/devices, encrypt your plaintext to each, then post. Every active member must be covered by at least one recipient device, or the request is rejected.

Valid kind values: text, image, voice, video, file, location, poll.

Request

{
  "conversation_id": "8f1c…",
  "kind": "text",
  "reply_to_message_id": null,
  "media_object_id": null,
  "forwarded": false,
  "recipients": [
    { "device_id": "d1a2…", "ciphertext": "base64…", "nonce": "base64…" },
    { "device_id": "d3b4…", "ciphertext": "base64…", "nonce": "base64…" }
  ]
}

For a poll, set kind: "poll" and add a poll block. The server assigns option IDs (so votes are tallied by ID without the server seeing option text — those live inside the ciphertext):

{
  "conversation_id": "8f1c…",
  "kind": "poll",
  "poll": { "option_count": 3, "multi_choice": false, "anonymous": false, "closes_at": null },
  "recipients": [ /* … */ ]
}

Response 201

{
  "message": {
    "id": "m9z8…",
    "conversation_id": "8f1c…",
    "sender_user_id": "u100",
    "sender_device_id": "d1a2…",
    "kind": "text",
    "reply_to_message_id": null,
    "thread_root_id": null,
    "media_object_id": null,
    "system_payload": null,
    "created_at": "2026-06-16T10:00:00Z",
    "edited_at": null,
    "forwarded_at": null,
    "deleted_at": null
  }
}

Notes

are accepted.

resolved thread_root_id.

([{ id, position }]).

GET /conversations/:id/messages

List messages in a conversation, newest first. Scope: messages:read.

Query parameters

ParamDescription
limitPage size, default 50, max 200
beforeMessage ID cursor — returns messages older than it

Each returned message includes the ciphertext/nonce addressed to your device (so you can decrypt locally), plus reactions, poll state, thread metadata, and read/delivery aggregates.

Response 200

{
  "messages": [
    {
      "id": "m9z8…",
      "conversation_id": "8f1c…",
      "sender_user_id": "u100",
      "sender_device_id": "d1a2…",
      "kind": "text",
      "reply_to_message_id": null,
      "thread_root_id": null,
      "media_object_id": null,
      "created_at": "2026-06-16T10:00:00Z",
      "edited_at": null,
      "deleted_at": null,
      "ciphertext": "base64…",
      "nonce": "base64…",
      "delivered_at": "2026-06-16T10:00:01Z",
      "read_at": null,
      "any_delivered_at": true,
      "any_read_at": false,
      "reactions": [{ "user_id": "u101", "emoji": "👍", "created_at": "…" }],
      "poll": null,
      "thread": null
    }
  ],
  "next_cursor": "m7y6…"
}

ciphertext: null means no device of yours was a recipient (e.g. the bot device joined after the message was sent) — the envelope is returned anyway.

POST /messages/:id/delivered

Mark this device's copy as delivered. Scope: messages:write. Body: none. Returns { "ok": true }.

POST /messages/:id/read

Mark this device's copy as read (also advances the conversation read pointer). Scope: messages:write. Body: none. Returns { "ok": true }.

PUT /messages/:id

Edit a message by replacing its per-recipient ciphertexts. Scope: messages:write. Sender-only, from the original sending device, within 15 minutes, kind: "text" only.

Request

{ "recipients": [ { "device_id": "d1a2…", "ciphertext": "base64…", "nonce": "base64…" } ] }

Response 200 — the updated message envelope (with edited_at set).

DELETE /messages/:id

Delete a message. Scope: messages:write. The sender can always delete their own message; conversation owners/admins can delete any message. Ciphertext is wiped and kind becomes "deleted". Returns { "ok": true }.

Reactions

{ "reactions": [{ "user_id", "emoji", "created_at" }] }.

{ "emoji": "🎉" }. Returns { "ok": true }.

{ "ok": true }.

Pins

At most 3 pins per conversation; pinning a 4th evicts the oldest.

message.

{ "ok": true }.

{ "pins": [...] } with your device's ciphertext per pin.

Scheduled messages

A scheduled message is a pre-sealed fanout that the server fires at send_at. You build the per-device ciphertexts now, exactly as for an immediate send.

``json { "conversation_id": "8f1c…", "send_at": "2026-06-20T09:00:00Z", "kind": "text", "reply_to_message_id": null, "recipients": [ { "device_id": "d1a2…", "ciphertext": "base64…", "nonce": "base64…" } ] } ``

send_at must be ≥ 15 seconds and ≤ 1 year in the future. Returns { "scheduled": { "id", "conversation_id", "send_at", "kind", "recipient_count", … } }.

Lists your pending (not yet sent/canceled) scheduled messages.

pending scheduled message.


5.2 Conversations

GET /conversations

List conversations you are a member of, with last-message preview metadata and (for direct chats) the peer's profile. Scope: conversations:read.

Response 200

{
  "conversations": [
    {
      "id": "8f1c…",
      "kind": "group",
      "workspace_id": null,
      "title": "Design Team",
      "avatar_url": null,
      "only_admins_send": false,
      "message_ttl_seconds": null,
      "my_role": "admin",
      "notif_level": "all",
      "muted_until": null,
      "pinned_at": null,
      "archived_at": null,
      "last_read_message_id": "m7y6…",
      "last_message": { "id": "m9z8…", "kind": "text", "created_at": "…" },
      "last_message_ciphertext": "base64…",
      "last_message_nonce": "base64…",
      "peer": null
    }
  ]
}

POST /conversations

Create a conversation. Scope: conversations:write.

Request

{
  "kind": "group",
  "workspace_id": null,
  "title": "Design Team",
  "avatar_url": null,
  "member_user_ids": ["u100", "u101", "u102"]
}

Response 201{ "conversation": { … } }.

GET /conversations/:id/info

Full conversation record + member list (with display names, roles, join times). Scope: conversations:read.

{
  "conversation": { "id": "8f1c…", "kind": "group", "title": "Design Team", "my_role": "admin", … },
  "members": [
    { "id": "u100", "username": "alex", "display_name": "Alex", "role": "owner", "joined_at": "…" }
  ]
}

PUT /conversations/:id

Update group/channel metadata. Scope: conversations:write. Admin-only for admin fields.

Request (any subset)

{
  "title": "New name",
  "avatar_url": "https://…",
  "description": "…",
  "only_admins_send": true,
  "only_admins_edit_info": true,
  "message_ttl_seconds": 86400
}

message_ttl_seconds enables disappearing messages (60s–30d, or null to disable). Returns { "conversation": { … } }.

Members

only. Body { "user_ids": ["u103", "u104"] }. Returns { "ok": true }.

conversations:write. Admin only (or self-leave). Returns { "ok": true }.

conversations:write. Owner only. Body { "role": "admin" } (owner | admin | member). Returns { "ok": true }.

GET /conversations/:id/devices

List every active recipient device for the conversation, with public keys — the input you need to encrypt a message. Scope: conversations:read. Caller must be a member.

Response 200

{
  "devices": [
    {
      "id": "d1a2…",
      "user_id": "u100",
      "kind": "mobile",
      "label": "Alex's iPhone",
      "fingerprint": "9f:2c:…",
      "identity_public_key": "base64-NaCl-public-key"
    }
  ]
}

To send: encrypt plaintext to each device's identity_public_key, then post the resulting { device_id, ciphertext, nonce } list to POST /messages.


5.3 Spaces (Workspaces)

"Spaces" in the product are workspaces in the API. They group channels, drive files, wiki pages, tasks, and calendar events.

GET /workspaces

List workspaces you belong to, each with its channels and member count. Scope: conversations:read. Returns { "workspaces": [ { "id", "name", "slug", "my_role", "channels": [...], "members_count": 4 } ] }.

POST /workspaces

Create a workspace (seeds a #general channel). Scope: conversations:write.

{ "name": "Acme Inc", "slug": "acme", "description": "…", "avatar_url": null }

Returns 201 { "workspace": { …, "my_role": "owner", "channels": [...], "members_count": 1 } }.

GET /workspaces/:id

Workspace detail + members + channels. Scope: conversations:read.

PUT /workspaces/:id

Update (owner/admin). Scope: conversations:write. Body any of { name, description, avatar_url, announcement }.

DELETE /workspaces/:id

Soft-delete (owner only). Scope: conversations:write. Returns { "ok": true }.

Invites

Body { "role": "member", "max_uses": 10, "expires_at": "…" } (all optional). Returns { "invite": { "code": "abc123…", … } }.

Owner/admin. Body { "email": "x@y.com" } or { "phone": "+49…" }, optional role. Generates a single-use 14-day invite and emails/SMSes a join link. Returns { "invite", "link", "dispatched": "email" | "sms" | null }.

{ "code": "abc123…" }. Joins the workspace and auto-adds you to all its channels. Returns { "workspace_id": "…" }.

Channels

Owner/admin. Body { "name": "general", "description": "…", "is_announcement": false }. All active workspace members are added. Returns { "channel": { … } }.

Members

effective permissions. Returns { "members": [...], "role_defaults": { … } }.

Owner/admin. Body { "role": "admin", "permissions": { "can_post": true } }. Roles: owner | admin | member | guest. Only an owner may grant owner; the last owner cannot be demoted.

Owner/admin (or self-leave). Returns { "ok": true }.

Drive (files)

Files reference an already-uploaded media object. The drive adds a folder/tag/version layer.

(search), tag, folder (parent folder ID; omitted = root), limit. Returns { "files": [...] } (folders first, only the newest version per group).

object you uploaded:

``json { "media_object_id": "med-…", "name": "Spec.pdf", "description": "…", "tags": ["spec"], "parent_folder_id": null } ``

Re-attaching the same name in the same folder creates a new version (capped at 5). Returns { "file": { … } }.

{ "name": "Designs", "parent_folder_id": null }. Returns { "folder": { … } }.

{ name?, description?, tags? }. Uploader or admin.

Soft-delete. Uploader or admin.

Owner/admin. Body { "pinned": true }.

Version history. Returns { "versions": [...] }.

Wiki pages

Markdown notes per workspace; any member can create, author/admin can edit.

limit. Returns { "pages": [...] }.

{ "title": "Onboarding", "body": "# Welcome…", "parent_page_id": null }. Returns { "page": { … } }.

{ title?, body?, pinned? }. Author or admin only.

Soft-delete. Author or admin.

Workspace search

query across channels, drive files, tasks, calendar events, and wiki pages (q ≥ 2 chars).

``json { "channels": [...], "files": [...], "tasks": [...], "events": [...], "pages": [...] } ``

API keys (workspace-managed)

{ "label": "CI bot", "scopes": ["messages:write"], "expires_at": null, "crm_device_id": null }. Returns { "api_key": { … }, "secret": "koro_live_…" }secret shown once.

These three are authenticated as a workspace owner/admin (the portal session), not by the API key itself.

5.4 Meetings

Multi-participant video meetings (Google-Meet style). Meetings support both Koro users and anonymous guests, so several endpoints are reachable without a Bearer token; an API key still works for the authenticated paths.

POST /meetings

Create an instant or scheduled meeting.

Instant

{ "title": "Standup", "description": "…", "max_participants": 50, "allow_guests": true }

Scheduled — provide either a full ISO-8601 scheduled_at, or split date + time + utc_offset:

{ "title": "Roadmap review", "scheduled_at": "2026-06-20T15:00:00Z", "workspace_id": null }

Response 201

{
  "meeting": { "id": "mt-…", "room_id": "abc-def1-gh2", "title": "Standup", "scheduled_at": null, … },
  "room_id": "abc-def1-gh2",
  "url": "https://meet.koro.chat/m/abc-def1-gh2"
}

GET /meetings

List meetings you host or have joined. Returns { "meetings": [...] }.

GET /meetings/:roomId

Meeting details + participant roster.

{ "meeting": { … }, "participants": [ { "id", "display_name", "is_host", "mic_on", "camera_on", "joined_at", "left_at" } ] }

GET /meetings/:roomId/ice-servers

ICE/TURN server list for a participant's WebRTC connection. Returns { "ice_servers": [...] }.

POST /meetings/:roomId/join

Register a participation. Body { "device_id": "…", "display_name": "…", "avatar_url": null }. Returns { "meeting": { … }, "participant": { … } }. Honors locks, guest policy, bans, the active-participant cap, and the scheduled-start gate.

POST /meetings/:roomId/leave

Mark your participation closed. Body { "device_id": "…" }. If the host leaves, the meeting ends for everyone. Returns { "ok": true } (or { "ok": true, "ended": true }).

POST /meetings/:roomId/start

Host-only. Skip the scheduled countdown and open the room now. Returns { "meeting": { … } }.

POST /meetings/:roomId/end

Host-only. End the meeting and drop all participants. Returns { "ok": true }.

PATCH /meetings/:roomId

Host-only update. Body any of { title, description, locked, allow_guests, max_participants, scheduled_at }. Returns { "meeting": { … } }.

DELETE /meetings/:roomId

Host-only. Hard-delete the meeting. Returns { "ok": true }.

Meetings also expose chat (GET/POST /meetings/:roomId/messages), participant kick, and PDF pinning — see the meeting host docs.

5.5 Tasks

GET /tasks

List tasks you created, are assigned to, or that belong to your workspaces. Scope: tasks:read.

Query parameters: workspace_id, assignee_id (or me), status, source, mine=true, limit (default 100, max 500).

{ "tasks": [ { "id": "t-…", "title": "Ship v2", "status": "open", "priority": "high", "due_at": "…", "assignee_user_id": "u100", "source_conversation_id": null } ] }

POST /tasks

Create a task. Scope: tasks:write.

{
  "title": "Ship v2",
  "description": "…",
  "priority": "high",
  "status": "open",
  "due_at": "2026-06-30T17:00:00Z",
  "workspace_id": null,
  "list_id": null,
  "parent_id": null,
  "assignee_user_id": "u100",
  "recurrence": "FREQ=WEEKLY;INTERVAL=1",
  "checklist": [{ "text": "Write tests", "done": false }]
}

priority: low | med | high | urgent. recurrence is an RRULE-style string (DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL); a new instance is spawned when a recurring task is completed. Returns { "task": { … } }.

PUT /tasks/:id

Update. Scope: tasks:write. Creator, assignee, or workspace admin. Body any of { title, description, priority, status, due_at, assignee_user_id, list_id, parent_id, recurrence }. Setting status: "done" stamps completion. Returns { "task": { … } }.

DELETE /tasks/:id

Soft-delete (creator only). Scope: tasks:write. Returns { "ok": true }.

Lists

{ "lists": [...] }.

{ "name": "Backlog", "workspace_id": null, "position": 0 }.

Time tracking

(closes any other open entry for you). Returns { "entry": { … } }.

entry. Returns { "entries": [...] }.


5.6 Calendar

Events can be personal or workspace-scoped, with optional recurrence (RRULE) and optional provider sync (apple / google).

GET /calendar/events

List your events ∪ events in your workspaces. Query limit (max 500). Returns { "events": [...] }.

POST /calendar/events

Create an event.

{
  "title": "Roadmap review",
  "description": "…",
  "starts_at": "2026-06-20T15:00:00Z",
  "ends_at": "2026-06-20T16:00:00Z",
  "location": "Room 4",
  "workspace_id": null,
  "conversation_id": null,
  "recurrence": "FREQ=WEEKLY;BYDAY=MO",
  "provider": null
}

title + starts_at are required. Workspace events require membership. Returns { "event": { … } }.

PUT /calendar/events/:id

Update an event you own (or a workspace event you can edit). Body any of { title, description, starts_at, ends_at, location, recurrence }. Returns { "event": { … } }.

DELETE /calendar/events/:id

Delete an event you own. Returns { "ok": true }.

Provider linking (/calendar/links, /calendar/oauth/:provider/begin|finish) is an OAuth flow intended for the end-user apps, not server-to-server keys.

5.7 Reminders

Personal, time-based reminders, optionally anchored to a task, conversation, or message.

GET /reminders?window=upcoming|past|all

List your reminders (default upcoming). Returns { "reminders": [...] }.

POST /reminders

{ "title": "Call supplier", "body": "…", "remind_at": "2026-06-18T09:00:00Z", "task_id": null, "conversation_id": null, "message_id": null }

remind_at must be in the future (5-min skew tolerance) and ≤ 2 years out. Returns { "reminder": { … } }.

PUT /reminders/:id

Body any of { title, body, remind_at, dismissed, snoozed_until }. Returns { "reminder": { … } }.

DELETE /reminders/:id

Returns { "ok": true }.

Snooze / complete


5.8 Users

GET /users/me

Your (the key's bound user) profile. Scope: users:read. Returns { "user": { … } }.

GET /users/search?q=…

Search by @username (prefix), +phone (exact E.164), or display-name substring. Scope: users:read. q must be ≥ 3 chars. Returns { "users": [ { "id", "username", "display_name", "avatar_url", "account_type" } ] }.

GET /users/:id

Public profile of a user. Scope: users:read. Respects the target's show_last_seen privacy setting. Returns { "user": { … } }.

GET /users/by-username/:username

Resolve a username (case-insensitive) to a public profile. Scope: users:read. Returns { "user": { … } }.

Block / unblock

{ "ok": true }.


5.9 Media

Media bytes are stored server-side; for conversation-scoped media the content key is wrapped per recipient device (E2E), so each device unwraps its own copy. Avatars and meeting PDFs are public objects.

POST /media/upload

Raw-body upload. The request body is the file bytes (not JSON). Headers:

HeaderRequiredNotes
Content-TypeyesThe file's MIME type, e.g. image/jpeg
Content-LengthyesEnforces the size limit
X-File-NamenoUsed for extension/picking
X-Conversation-IdnoScopes the media to a conversation (membership required)

Response 201

{
  "media": {
    "id": "med-…",
    "url": "https://api.koro.chat:3001/media/med-…",
    "mime_type": "image/jpeg",
    "size_bytes": 102400,
    "sha256": "…",
    "conversation_id": "8f1c…",
    "created_at": "…"
  }
}

POST /media/:id/recipients

Store the per-device wrapped content key for E2E (conversation-scoped) media. Uploader-only.

{ "recipients": [ { "device_id": "d1a2…", "wrapped_key": "base64…", "nonce": "base64…" } ] }

Returns { "ok": true }.

GET /media/:id/key

Return the wrapped content key for your device. Returns { "wrapped_key": "base64…", "nonce": "base64…" }.

GET /media/:id

Download the raw bytes. No Authorization header is required for public objects (avatars/PDFs); conversation-scoped media still enforces membership inside the handler. For encrypted media, decrypt the downloaded bytes with the key from GET /media/:id/key.

GET /conversations/:id/media

Per-conversation media gallery. Scope: conversations:read (member only). Query type=image|video. Returns { "media": [ { "id", "url", "mime_type", "size_bytes", "created_at", "uploader_user_id" } ] }.


5.10 Webhooks

Two independent mechanisms, both managed with the webhooks:manage scope:

into a conversation.

Outbound: register / list / delete

POST /webhooksscope: webhooks:manage. Owner/admin of the workspace.

{
  "workspace_id": "ws-…",
  "url": "https://example.com/koro-hook",
  "events": ["message.new", "task.updated"]
}

Response 201 — the webhook including its signing secret, shown once:

{ "webhook": { "id": "wh-…", "url": "https://example.com/koro-hook", "events": ["message.new", "task.updated"], "active": true, "secret": "base64…", "created_at": "…" } }

GET /webhooks?workspace_id=…scope: webhooks:manage. Lists hooks (without secrets) plus delivery health (last_success_at, last_failure_at, failure_count).

DELETE /webhooks/:idscope: webhooks:manage. Returns { "ok": true }.

Supported events

message.new            message.edited         message.deleted
call.started           call.ended
task.created           task.updated           task.completed
user.joined_workspace  user.left_workspace
workspace.created      conversation.created   conversation.member_added

An empty/omitted events array means "all events." See §6 for payload + signature details.

Inbound: per-conversation receivers

Let a third-party service (GitHub, Linear, Sentry, Zapier, or any generic JSON producer) post into a conversation. The payload is rendered as a plaintext system message.

POST /conversations/:id/inbound-webhooksscope: webhooks:manage. Conversation owner/admin.

{ "name": "GitHub: acme/api", "provider": "github", "use_hmac": true }

provider: github | linear | sentry | zapier | generic.

Response 201 — the receiver URL + HMAC secret, shown once:

{
  "hook": { "id": "ih-…", "name": "GitHub: acme/api", "provider": "github", "created_at": "…" },
  "url": "/hooks/in/koro_in_AbC123…",
  "hmac_secret": "base64…"
}

GET /conversations/:id/inbound-webhooksscope: webhooks:manage. Lists receivers. DELETE /conversations/:id/inbound-webhooks/:hook_id deactivates one.

The public receiver POST /hooks/in/:token (no auth) is the endpoint you hand to the external service. If the hook was created with use_hmac, the request must carry a valid provider-specific signature header:

ProviderSignature header
githubX-Hub-Signature-256: sha256=<hex>
linearX-Linear-Signature: <hex>
sentrySentry-Hook-Signature: <hex>
genericX-Koro-Signature: sha256=<hex>

The HMAC is computed over the raw request body using the hmac_secret. Bodies are capped at 256 KB. Rate-limited to 60 events/min/token.


5.11 AI

Optional AI helpers. If the server has no AI provider configured, these return 503 { "error": "AI not configured on this server" }. All operate on plaintext you supply — the server never decrypts your messages.

GET /ai/status

{ "enabled": true, "provider": "openai" }.

POST /ai/extract-tasks

Body { "text": "…", "context_hint": "…" } (text ≤ 4000 chars). Returns { "tasks": [ { "title", "description", "priority", "due_at", "confidence" } ] }.

POST /ai/smart-replies

Body { "context": ["…", "…"] } (last ≤ 10 messages, chronological). Returns { "replies": ["…", "…", "…"] }.

POST /ai/summarize

Body { "messages": ["…", …], "locale": "de" } (≤ 200 messages). Returns { "summary": "…" }.

POST /ai/translate

Body { "text": "…", "target": "en" }. Returns { "translated": "…", "source_lang": "de", "same_as_target": false }.

POST /ai/transcribe

Raw audio body (≤ 25 MB; Content-Type is the audio MIME). Optional query: target=<lang> (also translate), detect=1 (return source_lang). Returns { "text", "source_lang", "translated", "same_as_target", "target_lang" }.


6. Webhooks deep-dive

Outbound delivery

When a subscribed event fires, Koro POSTs to your URL with these headers:

HeaderExample
Content-Typeapplication/json
X-Koro-Eventmessage.new
X-Koro-Signaturesha256=<hex HMAC of body>
X-Koro-Delivery<delivery uuid>
X-Koro-Attempt1

The JSON body is the event name merged with the event payload:

{
  "event": "message.new",
  "message": {
    "id": "m9z8…",
    "conversation_id": "8f1c…",
    "sender_user_id": "u100",
    "sender_device_id": "d1a2…",
    "kind": "text",
    "created_at": "2026-06-16T10:00:00Z"
  },
  "recipient_count": 3
}
Payloads contain envelope metadata only — never plaintext. To read the message, fetch it via the API and decrypt with your bot device key.

A task.updated delivery looks like:

{ "event": "task.updated", "task": { "id": "t-…", "title": "Ship v2", "status": "done", … }, "patch": { "status": "done" } }

Retries

Koro retries non-2xx (or unreachable) deliveries with exponential backoff — 1, 5, 15, 60, 240, 1440 minutes (max 6 attempts total). Respond 2xx quickly (within 10s) to acknowledge. Make your handler idempotent and key on X-Koro-Delivery.

Signature verification (Node)

The signature is sha256=<HMAC-SHA256(secret, rawBody)>, hex-encoded. Verify against the raw request body before parsing:

import crypto from "node:crypto";

function verifyKoroSignature(rawBody: Buffer, header: string, secret: string): boolean {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express example (note express.raw so the body stays a Buffer):
app.post("/koro-hook", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyKoroSignature(req.body, req.header("X-Koro-Signature") ?? "", process.env.KORO_WEBHOOK_SECRET!);
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body.toString("utf8"));
  // … handle event.event …
  res.status(200).end();
});

7. SDK

The official koro-sdk (npm) handles the E2E crypto (device key management, per-recipient encryption, decryption) and wraps every endpoint in this reference.

npm install koro-sdk
// .env: KORO_TOKEN=koro_live_<prefix>_<secret>
import { Koro } from "koro-sdk";

const koro = new Koro({ token: process.env.KORO_TOKEN });

// Send an E2E message — the SDK fetches recipient devices, encrypts per device, and posts.
await koro.messages.send({ conversationId: "8f1c…", text: "Hello from my bot 👋" });

// Receive — decrypts each message with the bot's device key.
koro.bot.onMessage(async (msg) => {
  console.log(msg.conversationId, msg.text);
  await koro.messages.send({ conversationId: msg.conversationId, text: "Got it!" });
});

The SDK reads KORO_TOKEN from the environment automatically if you omit the token option.


8. Versioning & changelog

fields and endpoints without breaking existing ones. Treat unknown JSON fields as forward-compatible and ignore them.

prefix and announced in the Developer Portal with a migration window.

For questions or to report an issue, contact Koro developer support through the Developer Portal.