# API

# Quick Guide to Codecks API

_Version: Sep 2026_

> Open the [API Reference](https://manual.codecks.io/api-reference/) for a full overview of the models you can read and the
> actions you can call. Changes are listed in the [API Changelog](https://manual.codecks.io/api-changelog/).

The Codecks API is the same API that powers the web app. The parts listed in the [API Reference](https://manual.codecks.io/api-reference/) come with a written promise about how they may change, see [Stability](#stability).

## Using the TypeScript SDK

The recommended way to read data from the Codecks API is our [`@codecks/fetch`](https://www.npmjs.com/package/@codecks/fetch) package. You describe your query as a plain object, and it sends the request for you and infers the TypeScript type of the response.

> **Getting started with `@codecks/fetch`**
>
> Install the package via `npm install @codecks/fetch` and create the fetchers with your [token](#getting-a-token):
>
> ```ts
> import {buildFetchers} from "@codecks/fetch";
>
> const {fetchFromRoot} = buildFetchers({token: "cdxat_..."});
>
> const result = await fetchFromRoot({
>   account: {
>     fields: ["name"],
>     relations: {
>       cards: {
>         fields: ["title", "status"],
>         filter: {title: {op: "contains", value: "bug"}},
>         orderBy: "-createdAt",
>         limit: 10,
>       },
>     },
>   },
> });
>
> console.log(result.account.cards[0].title);
> ```
>
> Use `fetchInstance("card", cardId, {...})` to load a single card by its id. If a request fails, a `CodecksApiError` is thrown which contains the `status`, `code` and `path` described in [When a query is wrong](#when-a-query-is-wrong).
>
> Its types cover what the [API Reference](https://manual.codecks.io/api-reference/) documents, with `preview` items marked `@experimental` and deprecated ones `@deprecated`. The package also ships a markdown description of every documented model in `node_modules/@codecks/fetch/schema/`. If you use an AI coding tool like Claude Code or Cursor, point it to `schema/overview.md` as a starting point.

The package only covers reading data. For writing data, see [Writing data](#writing-data) below.

## Getting a token

Codecks offers two kinds of API tokens:

- **Organization tokens** (`cdxat_`) belong to your organization. They keep working if the person who created them leaves, and they can only access the projects you selected. Owners and admins can create them via **Organization Settings → Integrations → API Tokens**.
- **Personal tokens** (`cdxut_`) act on your behalf. They can access the same projects as you, cards and comments created with them show you as the author, and they can never do more than your role allows. You can create them via **Your Profile → API Tokens**. See [Personal tokens](#personal-tokens) below.

Both kinds are available on every plan. Each token belongs to exactly one organization.

When creating an organization token you choose two settings:

- **Permission:** what the token is allowed to do.
  - _Admin_: access to all projects in the organization, including projects created later. It can also perform admin actions such as creating and archiving projects or deleting files.
  - _Producer_: access to the selected projects, including producer actions like editing decks, project tags and forecasts.
  - _Other_: access to the selected projects only, either as _Read_ or _Read & write_. _Read_ only allows querying data. _Read & write_ also allows creating and changing cards, milestones, comments and attachments.
- **Projects:** the projects the token has access to. Everything else in the organization stays invisible to it, and projects created later are not added automatically. Admin tokens don't have this setting since they can access the whole organization.

Billing, integrations and token management are not available to API tokens, regardless of their permission. Managing members (inviting, changing roles, disabling, removing) is possible with an admin token.

Both kinds of tokens can have an optional expiry date. Once it has passed, the token stops working and requests are rejected with `token_expired`. Leave the date empty for a token that never expires.

The full token is only shown once, right after creating it. It looks like `cdxat_<id>_<secret>` (or `cdxut_<id>_<secret>` for a personal token). Make sure to copy it right away. Codecks only stores a hash and can't show it to you again. If you lose it, revoke the token and create a new one. Revoking takes effect immediately. Cards created by the token keep showing its name as the author.

The prefix tells you who is responsible for a token, e.g. when you find one in a log file or a repository: a `cdxut_` token belongs to a single person, a `cdxat_` token to the organization.

Send the token via the `Authorization` header:

```
Authorization: Bearer cdxat_...
```

The `X-Account` header is optional, since a token is always bound to its organization. If you do send it and it names a different organization, the request fails with `400 token_account_mismatch`.

### When a token is refused

If Codecks refuses a token, the response's `message` field tells you why:

| status | `message`                  | cause                                                                    |
| ------ | -------------------------- | ------------------------------------------------------------------------ |
| 401    | `invalid_token`            | a typo, a revoked token, or a token that was copied incompletely         |
| 401    | `token_expired`            | the token's expiry date has passed                                       |
| 401    | `not_a_member`             | a personal token whose owner was disabled                                |
| 401    | `user_api_tokens_disabled` | an admin switched off personal tokens for the organization               |
| 400    | `token_account_mismatch`   | the `X-Account` header names a different organization                    |
| 429    | `rate_limit`               | too many failed attempts from your IP, see [Restrictions](#restrictions) |

Apart from revoking, none of these delete the token. Once the reason goes away, e.g. after an admin turns personal tokens back on, the token works as before.

> The older `X-Auth-Token` header, which carried a copy of a browser login, stops working on
> **2026-12-31**. Please switch scripts that still use it to an API token before then.

> Visit our [Community Hub](https://www.codecks.io/community/) to see examples of how to integrate
> with Codecks.

## Personal tokens

Use a personal token when a script should act as you, e.g. to create cards under your name, work with the decks you have access to, or read the saved searches and notifications on your own desk. Its access is based entirely on your membership in the organization.

Personal tokens offer the same permission levels as organization tokens, but without picking projects: the token uses the projects you have access to. You can only choose levels up to your own role. If your role gets lowered later on, the token acts with the lower level until your role is raised again.

A few things to keep in mind:

- **Your membership counts.** If an admin disables your account, requests are rejected with `401 not_a_member`. If you are removed from the organization, your tokens for it are deleted. Once a disabled account is enabled again, its tokens work again as well.
- **Access to your own data.** Saved searches, notification state, your project selection and your profile can be read and, with _Read & write_ or above, also changed. Organization tokens can't access any of these.
- **Admins can revoke it.** Owners and admins see all personal tokens in **Organization Settings → Integrations → API Tokens** and can revoke them. You'll receive an email when this happens. They can't change your tokens, though.
- **Organizations can turn them off.** Admins can disable personal tokens for the whole organization. While disabled, no new tokens can be created and existing ones are rejected with `401 user_api_tokens_disabled`. Enabling them again restores all tokens. Nothing gets deleted.

Changing your password or logging out on all devices does not affect personal tokens, as these actions only end browser sessions. To revoke a personal token, go to **Your Profile → API Tokens**.

## Reading Data

All you need for a first request is the token:

```bash
curl 'https://api.codecks.io/' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer [TOKEN]' \
  --data-binary '{"query":{"_root":[{"account":["name"]}]}}'
```

This returns the name of your organization (= `account`):

```json
{
  "_root": {
    "account": "5740f013-150f-4552-ae62-070fe4de48e2"
  },
  "account": {
    "5740f013-150f-4552-ae62-070fe4de48e2": {
      "id": "5740f013-150f-4552-ae62-070fe4de48e2",
      "name": "My Studio"
    }
  }
}
```

The response is not nested like the query. Instead, every row is listed once under its model name and id, and relations only contain ids. Here `_root.account` holds the id of your organization, and the actual data is found under `account` with that id. A list like `cards` becomes an array of card ids, and the cards themselves are listed under `card`. The [TypeScript SDK](#using-the-typescript-sdk) turns this back into nested objects for you.

Each row always contains its id, even if you didn't ask for it. Most models call it `id`, but not all of them: a card's id is `cardId`. The [API Reference](https://manual.codecks.io/api-reference/) names the id of every model ("Identified by …"). Use the same name to filter, e.g. to load a known set of cards:

```json
{
  "_root": [
    {
      "account": [
        {
          "cards({\"cardId\":[\"[CARD_ID_1]\",\"[CARD_ID_2]\"]})": ["title", "status"]
        }
      ]
    }
  ]
}
```

If a query contains a field the token is not allowed to read, the response is a `403` naming that field, e.g. `account.billingEmail requires account:readAdmin`. Codecks never silently leaves out data. Personal settings such as `timezone` are the exception: a personal token can read them for your own user and receives `null` for everyone else.

Note that there is only this single endpoint for reading data. Use the `"query"` part to build a nested, GraphQL-like request that fetches exactly the data you need, however deeply nested.

We won't cover every detail here, but these example queries should give you an idea of what's possible:

**return all cards with their titles within your account (aka organization)**

```json
{"_root": [{"account": [{"cards": ["title"]}]}]}
```

**return all cards with their title whose title contains `[SEARCHTERM]`**

```json
{
  "_root": [
    {
      "account": [
        {
          "cards({\"title\":{\"op\":\"contains\",\"value\":\"[SEARCHTERM]\"}})": ["title"]
        }
      ]
    }
  ]
}
```

**return all decks with their ids and project names**

Useful for finding the `deckId` you need for filtering or [creating cards](#writing-data).

```json
{"_root": [{"account": [{"decks": ["title", {"project": ["name"]}]}]}]}
```

**return 10 cards from a deck ordered by creation date**

```json
{
  "_root": [
    {
      "account": [
        {
          "cards({\"deckId\":\"[DECK_ID]\",\"$order\":\"createdAt\",\"$limit\":10})": ["title"]
        }
      ]
    }
  ]
}
```

**equivalent to:**

```json
{
  "deck([DECK_ID])": [{"cards({\"$order\":\"createdAt\",\"$limit\":10})": ["title"]}]
}
```

**return all cards with an effort > 5 or effort <= 1**

```json
{
  "_root": [
    {
      "account": [
        {
          "cards({\"$or\":[{\"effort\":{\"op\":\"gt\",\"value\":5}},{\"effort\":{\"op\":\"lte\",\"value\":1}}]})": [
            "title"
          ]
        }
      ]
    }
  ]
}
```

**return your own user id (personal token)**

```json
{"_root": [{"loggedInUser": ["name"]}]}
```

The [API Reference](https://manual.codecks.io/api-reference/) describes the full query syntax, including counting rows with `count:cards`.

### When a query is wrong

If a query contains a mistake, the response is a `400` that tells you what went wrong and where:

```json
{
  "error": "unknown_field",
  "message": "'card' has no field 'titel' to filter by",
  "path": "_root.account.cards.titel",
  "statusCode": 400
}
```

`error` is a stable code your script can check against, and `path` points to the location in the query you sent. Some errors also include a `hint`, e.g. the list of keys that are allowed at that position.

| `error`                   | typical cause                                                                              |
| ------------------------- | ------------------------------------------------------------------------------------------ |
| `invalid_query_shape`     | a field list that is not a list, a filter object without `op`, `!` in front of a field     |
| `unknown_model`           | `cardz(…)` at the top level                                                                |
| `unknown_field`           | `titel` instead of `title`                                                                 |
| `unknown_relation`        | a relation the model does not have                                                         |
| `unknown_operator`        | `{"op": "like"}`                                                                           |
| `invalid_value`           | `"abc"` for a number, a malformed id or date, `in` without a list, an `op` without `value` |
| `unknown_special_key`     | `$limt` instead of `$limit`                                                                |
| `invalid_order`           | `$order` naming an unknown field, or given as an object instead of `"-createdAt"`          |
| `invalid_limit`           | `$limit` without `$order`, `$offset` without `$limit`, a negative or fractional `$limit`   |
| `missing_ids`             | `card` at the top level without an id                                                      |
| `relation_takes_no_query` | a query on a relation that points to a single row, such as `deck({…})` on a card           |
| `invalid_aggregate`       | `foo:cards`, or `count:deck` on a card                                                     |

If the token isn't allowed to read a field, the response is a `403` with `"error": "missing_scope"`, including the `path` and the `requiredScope`.

## Writing data

Every change is an action, sent as a `POST` to `/dispatch/[namespace]/[action]`. The [actions section of the API Reference](https://manual.codecks.io/api-reference/#actions) lists each one with its params and its response.

Here's an example for creating a card:

```bash
curl 'https://api.codecks.io/dispatch/cards/create' \
  -H 'Authorization: Bearer [TOKEN]' \
  -H 'Content-Type: application/json' \
  --data-binary '{"content":"My card content","deckId":"[DECK_ID]","effort":12,"priority":"b"}'
```

This creates a card and returns its `id` and `accountSeq`. Params an action doesn't know are ignored without an error, so double-check the spelling when a call seems to have no effect. If the token lacks the required permission, the response is a `403` naming it, e.g. `requires card:write` for a read-only token, or `requires project:create` when calling `projects/create` with a token below admin level. Actions that manage the organization itself (logging in, billing, integrations, token management) are not available to API tokens.

## Stability

Everything in the [API Reference](https://manual.codecks.io/api-reference/) has one of two levels:

- **Stable**: changes that could break your script are announced at least **6 months** before they happen.
- **Preview** (marked in the reference): may change in any release. Every change is listed in the [API Changelog](https://manual.codecks.io/api-changelog/). Where it's reasonable, the old form keeps working for 4 weeks after a change, but that isn't guaranteed.

Anything not in the reference is internal. This includes fields and actions you may find in your browser's network tab. They can change or disappear at any time without notice.

There are no version numbers. The API changes by adding new things and by deprecating old ones. Since every query names the fields it reads, a new field never reaches a script that didn't ask for it.

### What counts as a breaking change

| change                                                  | reading      | writing      |
| ------------------------------------------------------- | ------------ | ------------ |
| a new model, field, relation, action or optional param  | fine         | fine         |
| a new key in an action's response                       | —            | fine         |
| a new value for a field like `status` or `deckType`     | fine         | fine         |
| a new error code                                        | fine         | fine         |
| a new required param                                    | —            | **breaking** |
| a removed or renamed field, relation, action or param   | **breaking** | **breaking** |
| a changed type, e.g. a number becomes a string          | **breaking** | **breaking** |
| a field that was never `null` can now be `null`         | **breaking** | —            |
| stricter validation of a param                          | —            | **breaking** |
| a field or param keeps its name but changes its meaning | **breaking** | **breaking** |
| a token needs a different permission for the same thing | **breaking** | **breaking** |

For a stable item, a breaking change happens in steps: the replacement is added, the old item is deprecated, and both keep working for at least 6 months. A field that changes its meaning always gets a new name.

To keep your script working through changes that aren't breaking:

- **Expect new values.** A field like `status` or `deckType` may gain values your code hasn't seen before. Handle them instead of failing.
- **Ignore unknown keys** in action responses.
- **Use `$order`** when the order of rows matters. Without it, the order may change.
- **Check `error`, not `message`.** The `error` codes of a failed request are stable, the wording of `message` and `hint` is not.

Rate limits aren't part of the promise either.

### Deprecations

A deprecated item keeps working until its removal date. Every response that uses it carries a `Deprecation` and a `Sunset` header, plus a `Link` to its entry in the [API Changelog](https://manual.codecks.io/api-changelog/). Logging these headers in your script is the easiest way to learn about a deprecation in time.

## Restrictions

A single IP can perform 40 requests every 5 seconds before being rate-limited. Requests above that limit are answered with a `429` and a `retry-after` header telling your script how many seconds to wait. Failed authentication attempts are limited separately per IP, so a script that keeps retrying with a revoked token gets slowed down.

An organization may hold up to 50 organization tokens at a time. On top of that, every member may hold up to 20 personal tokens.

The API only accepts requests from browser pages served by Codecks itself. Calling it from your own web page fails with a CORS error, so run your scripts on a server or your own machine instead. This also keeps the token out of code that others can read.

## Example Snippets

Do you have a script or snippet you'd like to share? Feel free to get [in touch with us](mailto:hello@codecks.io)!

#### Add file to card (python)

Uploading files isn't part of the [API Reference](https://manual.codecks.io/api-reference/) yet. `GET /s3/sign` and `cards/addFile` are internal and may change without notice.

```py
import os
import requests
import mimetypes


def add_file_to_card(filepath, card_id, token):
    content_type = mimetypes.guess_type(filepath)[0]
    filename = os.path.basename(filepath)

    headers = {
        'Authorization': 'Bearer %s' % token,
    }

    # request s3 upload details
    response = requests.get("https://api.codecks.io/s3/sign?objectName=%s" % filename, headers=headers)
    response.raise_for_status()
    u = response.json()

    # upload file
    files = [('file', (filename, open(filepath, 'rb'), content_type))]
    payload = u['fields']
    payload["Content-Type"] = content_type
    requests.request("POST", u['signedUrl'], headers=headers, data=payload, files=files)

    # update card
    requests.request("POST", "https://api.codecks.io/dispatch/cards/addFile", headers=headers, json={
        "cardId": card_id,
        "fileData": {
            "fileName": filename,
            "url": u['publicUrl'],
        }
    })
```
