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:3001All 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
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
- Open the Koro Developer Portal and select the workspace (Space) the key
should act on behalf of.
- Go to Settings → API keys → Create key.
- Give it a label, pick the scopes it needs, and (optionally) bind it
to a bot device and/or set an expiry.
- 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
koro_live_…— production keys. (koro_test_…keys are also accepted by the
auth layer for sandbox use.)
- The
prefixportion is stored in plaintext so keys can be identified in the
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.
| Scope | Grants |
|---|---|
conversations:read | List/read conversations, members, devices |
conversations:write | Create conversations, manage members/roles/settings |
messages:read | List messages, reactions, pins |
messages:write | Send/edit/delete messages, react, pin, schedule |
calls:read | List calls, call history, call details |
calls:write | Start/join/leave/reject/end calls, fetch ICE servers |
spaces:read | List spaces (workspaces), members, workspace search |
spaces:write | Create/update spaces, invites, channels, join by code |
tasks:read | List tasks, lists, timers |
tasks:write | Create/update/delete tasks, lists, start/stop timers |
users:read | Look up users, search, profiles |
webhooks:manage | Register/list/delete outbound + inbound webhooks |
* | All of the above |
Security notes
- The secret is shown once. If you lose it, revoke the key and create a new
one.
- Rotate regularly and immediately on suspected compromise. Revoke via
- Never ship a
koro_live_key in client-side code (web bundles, mobile
apps, public repos). API keys are server-side credentials. They grant access to an entire workspace within their scopes.
- Keys can be given an expiry (
expires_at) at creation; expired keys are
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:
- A bot user + device — a real
usersrow (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.
- 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):
GET /workspaces/:id/bots— list bots.PUT /workspaces/:id/bots/:bot_id—{ name?, username?, avatar_url? }.DELETE /workspaces/:id/bots/:bot_id— delete bot (revokes its key + device).POST /workspaces/:id/bots/:bot_id/rotate-key— new key, old one revoked; secret shown once.
How a bot sends a message (E2E)
Because Koro is end-to-end encrypted, sending is a three-step dance:
- Fetch recipient devices for the conversation —
GET /conversations/:id/devices. You get every active member device with its identity_public_key.
- Encrypt your plaintext once per device with NaCl
box, producing a
{ device_id, ciphertext, nonce } triple per recipient.
- 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:
- Polling — call
GET /conversations/:id/messages on an interval. Each message includes the ciphertext/nonce addressed to your bot device, which you decrypt locally.
- Outbound webhooks — register a webhook
(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.
Seekoro-sdkfor 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
| Status | Meaning |
|---|---|
200 | OK |
201 | Created |
400 | Bad request — malformed JSON or missing/invalid fields |
401 | Unauthorized — missing, malformed, expired, or revoked API key |
403 | Forbidden — valid key but missing scope, or not permitted on this object |
404 | Not found |
429 | Too Many Requests — rate limit exceeded |
503 | Service 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
- Max 500 recipients per send.
- If the conversation has
only_admins_sendenabled, only owner/admin senders
are accepted.
- Replies collapse into a flat thread (Slack-style); the response carries the
resolved thread_root_id.
- For polls, the response
message.pollincludes the assignedoptions
([{ id, position }]).
GET /conversations/:id/messages
List messages in a conversation, newest first. Scope: messages:read.
Query parameters
| Param | Description |
|---|---|
limit | Page size, default 50, max 200 |
before | Message 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
GET /messages/:id/reactions— scope:messages:read. Returns
{ "reactions": [{ "user_id", "emoji", "created_at" }] }.
POST /messages/:id/reactions— scope:messages:write. Body
{ "emoji": "🎉" }. Returns { "ok": true }.
DELETE /messages/:id/reactions/:emoji— scope:messages:write. Returns
{ "ok": true }.
Pins
At most 3 pins per conversation; pinning a 4th evicts the oldest.
POST /messages/:id/pin— scope:messages:write. Returns the pinned
message.
DELETE /messages/:id/pin— scope:messages:write. Returns
{ "ok": true }.
GET /conversations/:id/pins— scope:messages:read. Returns
{ "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.
POST /messages/scheduled— scope:messages:write.
``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", … } }.
GET /messages/scheduled?conversation_id=…— scope:messages:read.
Lists your pending (not yet sent/canceled) scheduled messages.
DELETE /messages/scheduled/:id— scope:messages:write. Cancels a
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"]
}
kind:direct|group|channel.directrequires exactly 2 members (the caller is auto-added).- The caller becomes
ownerof a group/channel.
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
POST /conversations/:id/members— scope:conversations:write. Admin
only. Body { "user_ids": ["u103", "u104"] }. Returns { "ok": true }.
DELETE /conversations/:id/members/:userId— scope:
conversations:write. Admin only (or self-leave). Returns { "ok": true }.
PUT /conversations/:id/members/:userId/role— scope:
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
POST /workspaces/:id/invites— scope:conversations:write. Owner/admin.
Body { "role": "member", "max_uses": 10, "expires_at": "…" } (all optional). Returns { "invite": { "code": "abc123…", … } }.
POST /workspaces/:id/invite-by-contact— scope:conversations:write.
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 }.
POST /workspaces/join— scope:conversations:write. Body
{ "code": "abc123…" }. Joins the workspace and auto-adds you to all its channels. Returns { "workspace_id": "…" }.
Channels
POST /workspaces/:id/channels— scope:conversations:write.
Owner/admin. Body { "name": "general", "description": "…", "is_announcement": false }. All active workspace members are added. Returns { "channel": { … } }.
Members
GET /workspaces/:id/members— scope:conversations:read. Directory with
effective permissions. Returns { "members": [...], "role_defaults": { … } }.
PUT /workspaces/:id/members/:user_id— scope:conversations:write.
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.
DELETE /workspaces/:id/members/:user_id— scope:conversations:write.
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.
GET /workspaces/:id/files— scope:conversations:read. Query:q
(search), tag, folder (parent folder ID; omitted = root), limit. Returns { "files": [...] } (folders first, only the newest version per group).
POST /workspaces/:id/files— scope:conversations:write. Attach a media
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": { … } }.
POST /workspaces/:id/files/folders— scope:conversations:write. Body
{ "name": "Designs", "parent_folder_id": null }. Returns { "folder": { … } }.
GET /workspaces/:id/files/:file_id— scope:conversations:read. Detail.PUT /workspaces/:id/files/:file_id— scope:conversations:write. Body
{ name?, description?, tags? }. Uploader or admin.
DELETE /workspaces/:id/files/:file_id— scope:conversations:write.
Soft-delete. Uploader or admin.
POST /workspaces/:id/files/:file_id/pin— scope:conversations:write.
Owner/admin. Body { "pinned": true }.
GET /workspaces/:id/files/:file_id/versions— scope:conversations:read.
Version history. Returns { "versions": [...] }.
Wiki pages
Markdown notes per workspace; any member can create, author/admin can edit.
GET /workspaces/:id/pages— scope:conversations:read. Queryq,
limit. Returns { "pages": [...] }.
POST /workspaces/:id/pages— scope:conversations:write. Body
{ "title": "Onboarding", "body": "# Welcome…", "parent_page_id": null }. Returns { "page": { … } }.
GET /workspaces/:id/pages/:page_id— scope:conversations:read.PUT /workspaces/:id/pages/:page_id— scope:conversations:write. Body
{ title?, body?, pinned? }. Author or admin only.
DELETE /workspaces/:id/pages/:page_id— scope:conversations:write.
Soft-delete. Author or admin.
Workspace search
GET /workspaces/:id/search?q=…— scope:conversations:read. Fans the
query across channels, drive files, tasks, calendar events, and wiki pages (q ≥ 2 chars).
``json { "channels": [...], "files": [...], "tasks": [...], "events": [...], "pages": [...] } ``
API keys (workspace-managed)
GET /workspaces/:id/api-keys— owner/admin. Lists keys (without secrets).POST /workspaces/:id/api-keys— owner/admin. Body
{ "label": "CI bot", "scopes": ["messages:write"], "expires_at": null, "crm_device_id": null }. Returns { "api_key": { … }, "secret": "koro_live_…" } — secret shown once.
DELETE /api-keys/:id— owner/admin. Revokes the key. Returns{ "ok": true }.
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
GET /tasks/lists?workspace_id=…— scope:tasks:read. Returns
{ "lists": [...] }.
POST /tasks/lists— scope:tasks:write. Body
{ "name": "Backlog", "workspace_id": null, "position": 0 }.
Time tracking
POST /tasks/:id/timer/start— scope:tasks:write. Opens a time entry
(closes any other open entry for you). Returns { "entry": { … } }.
POST /tasks/:id/timer/stop— scope:tasks:write. Closes your open
entry. Returns { "entries": [...] }.
GET /tasks/:id/timer— scope:tasks:read. Entries +total_seconds.
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
POST /reminders/:id/snooze— body{ "minutes": 15 }(1–1440, default 15).POST /reminders/:id/complete— marks it done/dismissed.
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
POST /users/:id/block— body{ "reason": "…" }(optional). Returns
{ "ok": true }.
DELETE /users/:id/block— unblock. Returns{ "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:
| Header | Required | Notes |
|---|---|---|
Content-Type | yes | The file's MIME type, e.g. image/jpeg |
Content-Length | yes | Enforces the size limit |
X-File-Name | no | Used for extension/picking |
X-Conversation-Id | no | Scopes 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:
- Outbound webhooks — Koro POSTs events to your URL.
- Inbound webhooks — third parties POST to Koro, which renders the payload
into a conversation.
Outbound: register / list / delete
POST /webhooks — scope: 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/:id — scope: 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-webhooks — scope: 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-webhooks — scope: 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:
| Provider | Signature header |
|---|---|
github | X-Hub-Signature-256: sha256=<hex> |
linear | X-Linear-Signature: <hex> |
sentry | Sentry-Hook-Signature: <hex> |
| generic | X-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:
| Header | Example |
|---|---|
Content-Type | application/json |
X-Koro-Event | message.new |
X-Koro-Signature | sha256=<hex HMAC of body> |
X-Koro-Delivery | <delivery uuid> |
X-Koro-Attempt | 1 |
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:
- Name + logo — shown on the consent screen.
- Redirect URIs —
https://…(orhttp://localhost…in dev) your callback. - Scopes — the maximum permissions the app may ever request.
- Confidential vs. Public — confidential apps get a
client_secret(server
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
| Scope | Grants |
|---|---|
profile:read | Read the user's profile (name, username, avatar). |
conversations:read | List the user's conversations. |
conversations:write | Create & manage conversations. |
messages:read | Read messages. |
messages:write | Send messages on the user's behalf. |
tasks:read / tasks:write | Read / manage tasks. |
webhooks:manage | Manage 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
- The API is currently unversioned (
v1implied). Paths are stable; we add
fields and endpoints without breaking existing ones. Treat unknown JSON fields as forward-compatible and ignore them.
- Breaking changes, when unavoidable, will be introduced under a versioned path
prefix and announced in the Developer Portal with a migration window.
- Deprecations are announced in the portal changelog before removal.
For questions or to report an issue, contact Koro developer support through the Developer Portal.