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