KoroKorodevelopers
SDK Dashboard

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. Login with Koro (OAuth)
  4. 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
calls:readList calls, call history, call details
calls:writeStart/join/leave/reject/end calls, fetch ICE servers
spaces:readList spaces (workspaces), members, workspace search
spaces:writeCreate/update spaces, invites, channels, join by code
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. Login with Koro (OAuth)

"Login with Koro" lets your app authenticate end users and act on their behalf — like "Sign in with Google", but end-to-end encrypted. The user scans a QR code with the Koro app, sees a consent screen listing exactly which permissions you requested, and approves. You receive a scoped access token that can call the API as that user — limited to the scopes they granted.

This is different from bots (section 3): a bot is your programmatic identity; an OAuth token acts as the user's identity, only within the granted scopes, and the user can revoke it at any time from their Koro settings.

8.1 Register an app

In the Developer Portal → Login mit Koro, create an app. You choose:

side); public apps (SPA / native, no backend) use PKCE instead.

You receive a client_id (public) and, for confidential apps, a client_secret shown once.

8.2 Scopes

ScopeGrants
profile:readRead the user's profile (name, username, avatar).
conversations:readList the user's conversations.
conversations:writeCreate & manage conversations.
messages:readRead messages.
messages:writeSend messages on the user's behalf.
tasks:read / tasks:writeRead / manage tasks.
webhooks:manageManage webhooks.

A token can only use the scopes the user actually granted; calls outside them return 403 { "error": "Missing scope: …" }.

8.3 The flow

your site ──POST /oauth/authorize──▶ Koro          (start; get grant_id + QR)
   │  render qr_payload as a QR code
   ▼
 user scans with Koro app ──▶ in-app consent screen ──▶ Allow
   │                                                      │
   │         Koro app opens <redirect_uri>?code=…&state=… ▼
   ◀──────────────────────────────────────────────────────
   │  POST /oauth/token (code → tokens)
   ▼
 access_token (acts as the user, scoped)

8.4 Endpoints

POST /oauth/authorize — start a consent session. No auth.

{
  "client_id": "koro_app_…",
  "scopes": ["profile:read", "messages:read"],
  "redirect_uri": "https://your.app/auth/koro/callback",
  "state": "<opaque csrf token>",
  "code_challenge": "<base64url S256>",        // required for public clients (PKCE)
  "code_challenge_method": "S256",
  "ephemeral_public_key": "<b64 X25519>"        // optional: to receive the sealed device secret
}

{ grant_id, pairing_code, qr_payload, expires_at, requested_scopes }. Render qr_payload as a QR code (it's a JSON string). The grant expires in ~5 min.

GET /oauth/grants/:id — poll status (and the consent metadata the app shows). No auth. Returns { status: "pending"|"approved"|"consumed"|"denied"|"expired", app, requested_scopes, scope_info, … }. The authorization code is never returned here — it reaches you only via the redirect.

POST /oauth/token — exchange a code (or refresh). No bearer; the client authenticates in the body.

// Authorization code → tokens
{ "grant_type": "authorization_code", "client_id": "…", "client_secret": "…",
  "code": "…", "code_verifier": "…", "redirect_uri": "…" }

// Refresh
{ "grant_type": "refresh_token", "client_id": "…", "client_secret": "…",
  "refresh_token": "…" }

{ access_token, token_type:"Bearer", expires_in, refresh_token, scope, user, device_secret_* }.

Use the access_token as a normal Authorization: Bearer … against the API. Access tokens are short-lived (1 h default); rotate with the refresh token (30 days, sliding).

8.5 The user can disconnect

Users see connected apps under Settings → Verbundene Apps and can revoke any app, which immediately invalidates its tokens and the per-grant device.

8.6 With the SDK

import { Koro } from 'koro-sdk';

const oauth = Koro.oauth();                    // no token required
const { grantId, qrPayload, codeVerifier } = await oauth.startLogin({
  clientId: process.env.KORO_CLIENT_ID!,
  scopes: ['profile:read', 'messages:read'],
  redirectUri: 'https://your.app/auth/koro/callback',
  state: csrf,
  pkce: true,                                  // for public clients
});
// → render `qrPayload` as a QR code. After the user approves, your callback gets ?code=…

// In your callback:
const tokens = await oauth.exchangeCode({
  clientId: process.env.KORO_CLIENT_ID!,
  clientSecret: process.env.KORO_CLIENT_SECRET, // omit for public/PKCE
  code,
  codeVerifier,                                 // for PKCE
});

const koro = Koro.fromUserToken(tokens.accessToken);
const me = await koro.me();                     // acts as the user

To open end-to-end-encrypted message copies addressed to the connection, pass an ephemeralPublicKey to startLogin (via OAuth.generateEphemeralKey()), then after the exchange derive the secret with oauth.openDeviceSecret(tokens, ephemeralSecretKey) and construct new Koro({ token: tokens.accessToken, botSecret: deviceSecret }).


9. 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.