> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.taktoria.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.taktoria.com/_mcp/server.

# Errors and pagination

> How the Taktoria API reports errors, applies rate limits, treats PATCH and drafts, pages lists with cursors, and searches with q.

## Errors

Every non-2xx response has the same shape:

```json
{
  "error": {
    "code": "steps_incomplete",
    "message": "3 required steps still need a decision.",
    "details": { "steps": [{ "id": "stp_1", "name": "Privacy Review", "status": "NOT_STARTED" }] }
  }
}
```

| Status | Code                                                             | Meaning                                                                             |
| ------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| 401    | `unauthorized`                                                   | Missing, invalid, expired or revoked key                                            |
| 402    | `subscription_required`                                          | App not active, trialing or past due: `details` says why                            |
| 402    | `checkout_required`                                              | No subscription to change yet: `details.url` is the Checkout                        |
| 403    | `insufficient_scope`                                             | The key lacks the scope this endpoint needs                                         |
| 403    | `read_only_session`                                              | A signed-in session cannot make this change: use a key                              |
| 403    | `seat_required`                                                  | A signed-in person without a seat on the app                                        |
| 403    | `admin_required`                                                 | Signed in, not an admin of the workspace or app it changes                          |
| 403    | `viewer_only`                                                    | Signed in as a Viewer of the app: it can be read, not changed                       |
| 403    | `google_sign_in_required`                                        | The workspace requires a session signed in with Google                              |
| 403    | `forbidden`                                                      | Not a member of that workspace, or not one who may change this                      |
| 403    | `person_required`                                                | A person's own (a docs tool export): sign in, not a key                             |
| 404    | `not_found`                                                      | No such resource in your organization                                               |
| 409    | `conflict`, `steps_incomplete`, `draft`, `seat_limit_reached`, … | The request conflicts with current state                                            |
| 409    | `workspace_admin`                                                | A workspace owner's or admin's app roles cannot be changed                          |
| 409    | `launched`                                                       | A launched launch cannot go back to being a draft                                   |
| 409    | `ticketing_not_connected`                                        | The ticketing system is not connected to the workspace                              |
| 409    | `ticketing_reconnect`                                            | The ticketing system turned the connection down: connect again                      |
| 409    | `docs_not_connected`                                             | The person has not connected their account in that docs tool                        |
| 409    | `docs_reconnect`                                                 | The docs tool turned the person's connection down: connect again                    |
| 409    | `docs_tool_unavailable`                                          | This deployment does not export to that docs tool                                   |
| 409    | `docs_destination_invalid`                                       | That place in the docs tool cannot take a page, or is out of reach                  |
| 409    | `artifact_empty`                                                 | The artifact has nothing written in it to export                                    |
| 415    | `unsupported_media_type`                                         | An upload not sent as `multipart/form-data`                                         |
| 422    | `validation_error`                                               | The body failed validation; `details` holds the issues                              |
| 422    | `below_floor`                                                    | Fewer seats than the app's minimum or the seats handed out                          |
| 422    | `domain_not_allowed`                                             | Email domains off the allow-list: `details.domains` names them                      |
| 429    | `rate_limited`                                                   | A rate limit is used up: wait `Retry-After` seconds                                 |
| 502    | `billing_failed`, `domain_setup_failed`                          | Stripe or Vercel turned it down; nothing was saved                                  |
| 502    | `upstream_error`                                                 | memegen.link, a meme's background, a ticketing system or a docs tool did not answer |

An endpoint's own codes are listed in its reference.

## Rate limits

Every authenticated request is counted, after the key or session is checked
and before anything else. Reads are `GET` and `HEAD`; everything else is a
write. Limits are a sliding window: a request counts for one minute after
it is made, so there is no moment when a whole new allowance arrives at
once.

| Limit                                | Counted per                         | Reads / minute | Writes / minute |
| ------------------------------------ | ----------------------------------- | -------------- | --------------- |
| `api-key-read`, `api-key-write`      | API key                             | 600            | 120             |
| `session-read`, `session-write`      | Signed-in person                    | 300            | 60              |
| `mcp-read`, `mcp-write`              | MCP connection (person and client)  | 600            | 120             |
| `workspace-read`, `workspace-write`  | Workspace: every key and connection | 3,000          | 600             |
| `search`                             | Caller                              | 120            |                 |
| `dory-export`                        | Caller                              | 10             |                 |
| `meme-render`, `memegen-link-search` | Caller                              | 60, 30         |                 |
| `okr-bulk`                           | Caller                              |                | 20              |
| `ticketing-sync`                     | Caller                              |                | 20              |
| `docs-export`, `docs-browse`         | Signed-in person                    | 60             | 20              |

`search` is `GET /search` and the MCP servers' `search` tool;
`dory-export` is `GET /dory/events/{eventId}/export` and the
`export_questions` tool; `meme-render` is drawing a meme
(`GET /memes/{memeId}/image`, `POST /memes/preview` and the tools that
draw one) and `memegen-link-search` searching memegen.link
(`GET /meme-formats` and its tool); `okr-bulk` is
`POST /key-results/bulk-score` and `POST /objectives/import` and the
`bulk_score` and `bulk_create_objectives` tools; `ticketing-sync` is
linking a launch to what it follows in a ticketing system (a Linear
project, a Jira epic, a GitHub project or milestone, an Asana project, a
ClickUp list or task) and syncing it
(`PUT /launches/{launchId}/ticketing-project`,
`POST /launches/{launchId}/ticketing-project/sync` and their tools), which
read the ticketing system; `docs-export` is exporting an artifact to
Notion, Confluence or Google Docs
(`POST /launches/{launchId}/artifacts/{type}/exports` and the
`export_artifact` tool), and `docs-browse` listing where one can go
(`GET /docs-tools/{tool}/destinations` and `list_docs_destinations`). A request its own key
refuses is not counted against its workspace, so a key that is being
refused does not use up what the workspace's other keys share.

A key result's metric webhook (`POST /api/okrhub/metrics/{sourceId}`,
outside the versioned API and authenticated by the metric's own secret
rather than a key) has limits of its own: `metric-webhook-ip`, 600
requests per 10 minutes per address before the secret is checked, and
`metric-webhook`, 60 an hour per metric once it is. A metric moves daily,
not by the second: post when the number changes. See
[Metric webhooks](/getting-started/metric-webhooks).

Every answer to an authenticated request carries the IETF rate limit
headers ([draft-ietf-httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)),
one item per limit the request was counted against: the quota `q` in a
window of `w` seconds, how many remain (`r`) and how many seconds until the
window resets (`t`).

```http
RateLimit-Policy: "api-key-read";q=600;w=60, "workspace-read";q=3000;w=60
RateLimit: "api-key-read";r=598;t=41, "workspace-read";r=2871;t=41
```

Over a limit, the answer is `429` with `Retry-After` in seconds:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 7
RateLimit-Policy: "api-key-read";q=600;w=60
RateLimit: "api-key-read";r=0;t=7
```

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests: the api-key-read limit is 600 per minute. Try again in 7 seconds.",
    "details": { "policy": "api-key-read", "limit": 600, "windowSeconds": 60, "retryAfter": 7 }
  }
}
```

Nothing was done, so the request is safe to send again. Wait `Retry-After`
seconds first: requests sent while refused are counted too, so retrying at
once keeps a client shut out. A client that watches `r` and slows down as it
nears zero never sees a `429`. For bulk work, spread requests out rather than
sending them in bursts, and use a separate key for each integration so one
does not use up another's allowance.

## Updates

`PATCH` changes only what the body names. A field left out keeps its value,
whatever default it had when the resource was created, so renaming a launch
does not touch its status. Send `null` to clear an optional field. A list
(such as a key result's `ownerUserIds`) is replaced as a whole when sent.

## Drafts

Objectives, key results, launches and go links take a `draft` boolean, `false`
unless sent. A `PATCH` with `"draft": false` publishes, and the list endpoints
for each (objectives, an objective's key results, launches and go links) filter
with `?draft=true` (only drafts) or `?draft=false` (only what is published). What a draft holds back depends on the app:

| Resource              | While it is a draft                                                                                                                                                                    | Back to draft                               |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| Objective, key result | Nothing: it is a label. A draft is seen, scored, rolled up and checked in on like any other.                                                                                           | Yes                                         |
| Launch                | Announces nothing and notifies nobody, stays out of review queues, reminders and the weekly digest, and cannot be launched (`409 draft`). Publishing announces it to its stakeholders. | Yes, until it has launched (`409 launched`) |
| Go link               | Holds its slug, so nobody else can take it, but does not redirect: following it lands on Go Links, which says it is a draft.                                                           | Yes                                         |

A launch gets its template's steps once it has a planned date, draft or not:
when it is created with `plannedLaunchDate`, or, for a draft created without
one, when a `PATCH` first sets it or publishes it. A draft's reviewers hear of
their steps when it is published. Send `generateSteps: false` to leave the
steps off.

Dory events have no `draft` field: their times open and close them. An event
with a `startsAt` or `questionsOpenAt` is scheduled until the earlier of them
and then opens by itself (`status` `LIVE`, `phase` `scheduled`, then `live`).
One with neither is Not started (`status` `DRAFT`, `phase` `draft`): its join
code finds nothing until a host starts it with `status: LIVE`.

## Pagination

List endpoints are cursor-paginated:

```bash
curl "https://api.taktoria.com/api/v1/launches?limit=50&cursor=lch_7f3a" \
  -H "Authorization: Bearer $TAKTORIA_API_KEY"
```

```json
{ "data": [], "nextCursor": "lch_9c1b", "hasMore": true }
```

Pass `nextCursor` as the next request's `cursor`. `limit` defaults to 50 and
caps at 200.

## Searching

The list endpoints whose page in the app has a search box take `q`, the same
text that box sends, and match it the same way, so the API and the app find
the same things:

```bash
curl "https://api.taktoria.com/api/v1/launches?q=billing%20eu" \
  -H "Authorization: Bearer $TAKTORIA_API_KEY"
```

* The query is trimmed and cut to 100 characters, then split into words on
  spaces, at most 8 of them.
* Every word must appear somewhere in one of the fields the endpoint searches,
  in any order: `billing eu` finds a launch named "EU billing", not one that
  mentions only billing.
* Case and accents are ignored (`cafe` finds "Café"), and a word matches part
  of a field (`bill` finds "billing"). `%` and `_` are matched as themselves.
* `q` narrows the list alongside the endpoint's other filters, and pages the
  same way. Results are never wider than the list without `q`: a private
  objective, an event you may not run, or a draft go link's destination is not
  found by anybody who could not already see it.

| Endpoint                               | Searches                                                          |
| -------------------------------------- | ----------------------------------------------------------------- |
| `GET /launches`                        | Name, description, key customers, owners' names                   |
| `GET /reviews`                         | The step's name, its launch's name                                |
| `GET /objectives`                      | Title, description, key results' titles, owner's and team's names |
| `GET /dory/events`                     | Title, join code, description                                     |
| `GET /dory/events/{eventId}/questions` | The question, and who asked it unless they asked anonymously      |
| `GET /golinks`                         | Short name, description, destination                              |
| `GET /memes`                           | Captions, the template's name                                     |
| `GET /meme-templates`                  | Name                                                              |
| `GET /members`                         | Name, email address                                               |
| `GET /teams/organizational`            | Name                                                              |

`/launches`, `/dory/events`, `/golinks` and `/memes` also accept `search`,
the parameter's name before `q`. It is deprecated, matched the same way, and
ignored when `q` is sent.

### Searching a whole app

`GET /search?app=<slug>&q=` searches everything in one app at once, as its top
bar does, and returns each kind of thing as a group: how many match
(`total`) and the first five (`items`).

```bash
curl "https://api.taktoria.com/api/v1/search?app=okrhub&q=onboarding" \
  -H "Authorization: Bearer $TAKTORIA_API_KEY"
```

| `app`       | Groups, and the scope each needs                                                           |
| ----------- | ------------------------------------------------------------------------------------------ |
| `launchcal` | `launches` (`launches:read`), `steps` (`steps:read`)                                       |
| `okrhub`    | `objectives` (`objectives:read`), `keyResults` (`keyResults:read`), `teams` (`teams:read`) |
| `dory`      | `events` (`events:read`), `questions` (`questions:read`)                                   |
| `golinks`   | `links` (`golinks:read`)                                                                   |
| `memegen`   | `memes` (`memes:read`), `templates` (`memeTemplates:read`)                                 |

A group the key cannot read is left out; a key that can read none of an app's
groups gets `403 insufficient_scope`. A missing or unknown `app` is
`400 invalid_app`, and an app the workspace does not pay for is
`402 subscription_required`.