English
Cloud API
The Runeya cloud public API lets a third-party system add content to an organization's kanban boards. For example, a CI pipeline can open a card when a build breaks, a support form can add a request, and a monitoring tool can comment on the card it created earlier.
It is separate from the local server API. No machine needs to be running, and the key does not depend on a user session.
Getting a key
In the cloud app, Settings → Organization → API keys. The screen is restricted to organization owners and admins.
A key has three settings:
| A name | Used to recognise the key when you need to revoke it. |
| Permissions | kanban:read, kanban:write, kanban:comment, kanban:delete, each ticked separately. |
| Boards | Every board of the organization, or only the ones you pick. |
The secret is shown once
The cloud only stores a hash of it. A lost key cannot be recovered. You must revoke it and create a new one.
A key is tied to the person who created it
It acts on behalf of the person who created it. If that person leaves the organization, the key stops working, so no forgotten write access remains after they leave. For a long-term integration, create the key from an account that will stay in the organization.
First call
bash
curl https://api.runeya.dev/api/v1/boards \
-H "Authorization: Bearer rk_…"The response lists the boards the key can access, with their columns. You need a column title before adding a card.
Adding a card
bash
curl -X POST https://api.runeya.dev/api/v1/boards/<boardId>/cards \
-H "Authorization: Bearer rk_…" \
-H "Content-Type: application/json" \
-d '{
"column": "To do",
"title": "Build #4821 failed",
"markdown": "`yarn test` fails on `kanban-quota.test.ts`.",
"priority": 8,
"groups": ["ci"]
}'column accepts either the column id or its title, matched regardless of case and surrounding whitespace.
The write is per card: it adds one card without rewriting the board. Two simultaneous calls are both applied, and neither overwrites the changes of someone editing the board at the same time. The card shows up immediately for everyone looking at the board.
Every route
| Scope | ||
|---|---|---|
GET | /me | none |
GET | /members | kanban:read |
GET | /boards | kanban:read |
POST | /boards | kanban:write |
GET PATCH | /boards/{b} | kanban:read / kanban:write |
POST | /boards/{b}/columns | kanban:write |
POST | /boards/{b}/columns/reorder | kanban:write |
PATCH | /boards/{b}/columns/{id} | kanban:write |
DELETE | /boards/{b}/columns/{id} | kanban:delete |
POST | /boards/{b}/cards | kanban:write |
GET | /boards/{b}/cards/{c} | kanban:read |
PATCH | /boards/{b}/cards/{c} | kanban:write |
DELETE | /boards/{b}/cards/{c} | kanban:delete |
POST | /boards/{b}/cards/{c}/move | kanban:write |
GET | /boards/{b}/archive | kanban:read |
POST | /boards/{b}/cards/{c}/archive | kanban:write |
POST | /boards/{b}/cards/{c}/unarchive | kanban:write |
POST | /boards/{b}/cards/{c}/parent | kanban:write |
GET | /boards/{b}/cards/{c}/placements | kanban:read |
POST | /boards/{b}/placements | kanban:write |
DELETE | /boards/{b}/placements/{c} | kanban:write |
POST | /boards/{b}/cards/{c}/comments | kanban:comment |
POST | /boards/{b}/cards/{c}/attachments | kanban:write, or kanban:comment with ?comment= |
GET | /boards/{b}/cards/{c}/attachments/{id} | kanban:read |
DELETE | /boards/{b}/cards/{c}/attachments/{id} | kanban:delete |
Before writing a script, note the following:
- Assignment uses user ids. Names are not accepted.
GET /memberslists the ids. The displayed name is always read from the organization, so a person outside it cannot be assigned to a card. nullclears a field.{"priority": null}removes the card's priority. This is different from a priority of 1. The same applies tocoloranddeadline.deadlineis a date without a time, inYYYY-MM-DDformat. A timestamp would be displayed as the previous day for anyone west of Greenwich, so the card would look a day late for part of the team. A missingdeadlinemeans "no due date", never "overdue".- A key restricted to some boards cannot access a board created through the API. A key's board list is never extended automatically. The person who created the key must add the board.
Importing from another tool
POST /boards/{b}/cards accepts createdAt, a full ISO timestamp that cannot be in the future. It is the only date the caller can set, and only at creation. Without it, every card of an imported Jira or Trello board would have the same creation date, and you could no longer see how old each card is.
bash
curl -X POST "https://api.runeya.dev/api/v1/boards/<boardId>/cards" \
-H "Authorization: Bearer rk_…" -H 'Content-Type: application/json' \
-d '{"column":"To do","title":"Header redesign","createdAt":"2024-03-04T09:30:00Z"}'The "created" entry in the history shows the same date and states that it was supplied by the caller. updatedAt is still the time of the write. PATCH does not accept createdAt, because changing the creation date of a card others have already seen would rewrite its history. The caller can never set createdBy.
Archiving a card
An archived card is removed from the board but stays in the database. It no longer appears in any column, but it can still be read by its URL. unarchive puts it back in the column it was in, or in the board's first column if that column no longer exists. Archiving requires kanban:write and not kanban:delete, because it can be undone.
bash
# Put a card away
curl -X POST "https://api.runeya.dev/api/v1/boards/<boardId>/cards/<cardId>/archive" \
-H "Authorization: Bearer rk_…"
# Search the archive
curl "https://api.runeya.dev/api/v1/boards/<boardId>/archive?q=redesign" \
-H "Authorization: Bearer rk_…"
# Take it back
curl -X POST "https://api.runeya.dev/api/v1/boards/<boardId>/cards/<cardId>/unarchive" \
-H "Authorization: Bearer rk_…"Every card has archivedAt and archivedFrom fields. Both are null while the card is on the board.
An archived card is read-only. Editing its fields, attaching a file, commenting on it, moving it or giving it a parent all return 400 KANBAN_CARD_ARCHIVED, so that nobody changes a card that is no longer visible on the board. Only unarchive and deletion are allowed.
The archive returns at most 200 cards per call, most recently archived first. When more remain, the response contains truncated: true and a nextCursor. Pass that value as cursor to get the next page. Only nextCursor: null means you have read everything. Do not rely on the number of cards returned. The cursor is opaque: pass it back unchanged and never build one yourself. limit sets a smaller page size. A value above 200 is ignored and does not cause an error. The q search runs on the server over the title, the body, the comment text and the support conversations the card follows (title, messages and their authors' names). It ignores case and accents. To narrow a large archive, use q. Comments are searched because they often contain most of a card's history. Without them, anything written in a comment could not be found.
Data is never erased. DELETE on a card or a column, and deleting a board, hide the data from every read but keep it with its history and its files. A deletion cannot be undone through the API. The data is still in the database and can be recovered from there if someone deletes the wrong card.
Placing a card on several boards
A card can appear on other boards than its own without being copied. This is called a reference. The card stays on its origin board. On each board where it is placed, it has its own column and position.
bash
# Place card <cardId> in the "In progress" column of board <boardId>
curl -X POST "https://api.runeya.dev/api/v1/boards/<boardId>/placements" \
-H "Authorization: Bearer rk_…" -H "Content-Type: application/json" \
-d '{"cardId": "<cardId>", "column": "In progress"}'
# Where is it?
curl "https://api.runeya.dev/api/v1/boards/<boardId>/cards/<cardId>/placements" \
-H "Authorization: Bearer rk_…"
# Remove it from this board
curl -X DELETE "https://api.runeya.dev/api/v1/boards/<boardId>/placements/<cardId>" \
-H "Authorization: Bearer rk_…"Once placed, the card can also be accessed from that board. move only moves the reference. Editing, commenting, archiving or deleting changes the card itself, and therefore affects all its boards. When read from a board where it is a reference, the card has an origin: { boardId, boardName } field. On its own board, origin is null.
Removing a reference does not change the card, so it requires kanban:write and not kanban:delete. The key must have access to the card's origin board. Otherwise the response is 404, the same as for a card that does not exist. An archived card, a card whose origin is that board, or a card already placed there returns 400.
Attaching a file
The same route attaches a file to a card or to a comment. Without a parameter the file is attached to the card. With ?comment=<id> it is attached to that comment.
bash
curl -X POST "https://api.runeya.dev/api/v1/boards/<boardId>/cards/<cardId>/attachments?name=build.log" \
-H "Authorization: Bearer rk_…" \
--data-binary @build.logThe request body is the raw file. multipart is not used, because --data-binary @file is easier to write in a script than a multipart body.
The card, and the comment if there is one, are checked before any data is written to storage. If attaching fails, the file is removed, so no file is stored without a reference to it. The type a file is stored with is determined from its content, never from the declared Content-Type. Otherwise an html file could be run as a script by a preview.
Limits: 55 MB per file, 50 per card, 20 per comment. An instance with no object storage answers 501 storage_not_configured.
To download a file, use its card's URL. Access is checked on the card:
bash
curl "https://api.runeya.dev/api/v1/boards/<boardId>/cards/<cardId>/attachments/<id>" \
-H "Authorization: Bearer rk_…" -o build.logA file that is not attached to that card returns 404, even if it exists elsewhere.
Moving a card forward
bash
curl -X POST https://api.runeya.dev/api/v1/boards/<boardId>/cards/<cardId>/move \
-H "Authorization: Bearer rk_…" \
-H "Content-Type: application/json" \
-d '{"column": "In progress"}'What an error says
json
{ "error": { "code": "insufficient_scope", "message": "This API key lacks the \"kanban:write\" scope." } }| HTTP status | What happened |
|---|---|
401 | Key missing, malformed, unknown or revoked. Or key_owner_inactive: the person who created it has left the organization. |
403 | The key lacks the required scope, or the organization's plan forbids it (code starting with PLAN_LIMIT_). |
404 | The board does not exist, or the key has no access to it. Both cases return the same response. |
409 | The target column was removed between the read and the write (KANBAN_COLUMN_GONE), or the proposed card id is already taken (KANBAN_CARD_EXISTS). Nothing was written. |
413 | The body is over the limit. The message says which one: 256 kB for JSON, 55 MB for a file. |
An error for a missing scope gives the name of that scope, for example This API key lacks the "kanban:write" scope.. This tells you which box to tick on the key.
GET /api/v1/me returns the settings of the key you send. Use it to diagnose a 403.
Support integrations (Intercom, HubSpot)
Setup: Settings › Organization › Integrations (owners and admins). An integration stores the Intercom app client secret (webhook signature) and its access token (search and reading, read-only), which are both required. It also stores the Intercom workspace region and the automatic creation option. Only this option requires a board and a creation column.
Model. A conversation is stored once (integrationConversation, messages in integrationMessage). A card only stores a link to it (integrationCardLink). Several cards can follow the same conversation, and a card can follow several conversations. Messages are not comments. They are shown in the provider's tab on the card.
| At the provider | Effect |
|---|---|
| New conversation | Conversation recorded. If automatic creation is on and no card follows it, a card is created in the chosen column (auto link) |
| Reply, internal note | Message recorded, visible on every card following it |
| Resolved | auto card archived, only if it is still in its creation column |
| Reopened, or the customer writes again | auto card unarchived, with the same condition |
A card linked by hand (search from the card tab, or the MCP tool) is never archived or unarchived by the integration. Linking imports the whole thread up to that point. Files (attachments and inline images) are copied into the organization's attachment storage, because Intercom links expire. They open like card files and can be downloaded with kanban_download_attachment. Files are only fetched from Intercom hosts. A file that is too large or unreachable, or any file when no S3 storage is configured, is shown as not retrieved. The reason (with the host only) is written to the cloud logs. A conversation's Sync button re-reads its thread at Intercom, adds missing messages and retries files not retrieved. Search matches the conversation title. An Intercom conversation number or URL opens the conversation directly. Any member who can write to the kanban can link or unlink. Search results show which cards already follow each conversation. The Open link opens the conversation in Intercom. It only works for an account of that Intercom workspace.
Webhook. POST /api/integrations/webhook/<token>. This URL is shown on that screen and is outside /api/v1. The token identifies the connection, and the signature (X-Hub-Signature, HMAC-SHA1 with the client secret) proves the origin. Secrets are stored encrypted and never shown again.
- Replays are safe: conversations and messages are identified by the provider's ids. Archiving is triggered by a state change, so a replayed resolution does not archive again a card someone has restored.
- Codes:
404unknown token,401bad signature,200applied or impossible by configuration (integration disabled, board deleted, kanban refusal),500temporary error, in which case the provider retries. - Like a key, the integration acts on behalf of the person who created it. It stops working if that person leaves the organization.
Intercom topics: conversation.user.created, conversation.admin.single.created, conversation.user.replied, conversation.admin.replied, conversation.admin.noted, conversation.admin.closed, conversation.admin.opened.
HubSpot. A card is linked to an Inbox thread (email, form…), read through the Conversations v3 API. It needs a private app (a service key receives no webhooks) with the conversations.read scope, and preferably crm.objects.tickets.read and files.read. Differences from Intercom:
- Webhook:
X-HubSpot-Signature-v3signature (base64 HMAC-SHA256 of method + public webhook URL + body + timestamp, with the client secret), rejected after 5 minutes. The notification only contains ids. Each thread concerned is read again through the API (read-only), and one batch can contain several threads. Topics:conversation.creation,conversation.newMessage,conversation.propertyChange(statusproperty). - Title: the subject of the ticket created from the thread if there is one (
crm.objects.tickets.readscope). Otherwise, the first email subject without its "RE:" prefixes. Internal notes are the threadCOMMENTs. Assignments and status changes are ignored. - Search: a thread has no title, so text search looks in tickets and returns the thread each ticket came from. An Inbox URL (
…/live-messages/<portal>/inbox/<thread>), a thread number or a ticket record URL (…/record/0-5/<ticket>) opens it directly. - Files: served by
*.hubspotusercontent-*.net. Images inlined in an email point to the file manager, which requires a HubSpot session. They are replaced by a signed link through the Files API (files.readscope). Without that scope they are not retrieved. - Region:
na1oreu1, which only changes the Open link (app.hubspot.comorapp-eu1.hubspot.com).
Limits
There is currently no rate limiting. There is no limit per key, per organization or per address, and no 429 response. It is stated here because a missing limit is hard to detect. Without this note, an integrator could not tell whether there is no limit or whether the page is incomplete. Do not depend on it: a limit may be added later. A client that already handles 429 as "retry later" will not need any change when that happens.
Request size
| Limit | Above the limit | |
|---|---|---|
| JSON body | 256 kB | 413 payload_too_large |
Attached file (POST .../attachments) | 55 MB | 413 payload_too_large |
Field lengths
A field that is too long is rejected with 400. It is never truncated.
| Field | Limit |
|---|---|
title (card) | 500 characters |
markdown (card) | 100,000 characters |
comment | 100,000 characters, the same as markdown |
groups | 30 labels, 60 characters each |
assignees | 20 people |
attachments | 50 per card, 20 per comment |
priority | integer from 1 to 10 |
color | #RRGGBB |
deadline | YYYY-MM-DD |
name (board) | 200 characters |
description (board) | 10,000 characters |
instructions (board) | 20,000 characters |
| Columns per board | 100 |
title (column) | 200 characters |
Plan quotas
These depend on the organization's plan, not on the key. Going over a quota returns 403 with a PLAN_LIMIT_* code. Scripts should check this code. They should not check the message.
| Plan | Boards | Cards per board |
|---|---|---|
| Solo | 1 | 30 |
| Starter | 5 | 100 |
| Pro | unlimited | unlimited |
Quotas only block new additions. An organization that moves to a smaller plan keeps its existing boards and cards and can still edit them.
Reference
The specification is served by the API itself, publicly, at /api/v1/openapi.json. You can use it to generate a typed client.