# API keys and modes

API keys, their scopes and rotation, live and test mode, and the conventions every Kaabist endpoint shares: errors, pagination, idempotency and waiting.

Every request to the Kaabist API carries a key, and the key decides which organisation the request acts for, what it may do, and whether its jobs are live or test. The conventions on this page (errors, pagination, idempotency, waiting and response headers) are the same on every endpoint, so you read them once.

## When to use it

Read this page before you write a client. Use an `sk_test_` key while you develop and in CI, and an `sk_live_` key with the narrowest scope that works in production. If you use an SDK or the CLI, most of the conventions below are handled for you; the error codes and the meaning of each mode still apply.

## The base URL

```text
https://api.kaab.ist/v1
```

JSON in, JSON out, `snake_case` keys, UTF-8, timestamps in ISO-8601 UTC (`2026-09-18T10:00:00Z`) and durations in milliseconds unless the key ends in `_seconds`. The OpenAPI document every SDK is generated from is served at `GET /v1/openapi.json`.

## Authenticating

```http
Authorization: Bearer sk_test_…
```

A key belongs to one organisation and carries a **mode** and one or more **scopes**.

### Scopes

| Scope | Allows |
|---|---|
| `read` | every read: jobs, programs, sessions, proposals, schedules, usage |
| `run` | everything `read` allows, plus creating and acting on jobs, programs, aliases, proposals, sessions and schedules |
| `admin` | everything a key can do, including keys, webhooks, bring-your-own providers, organisation settings and deleting a program |

A request outside the key's scopes is `403`. No key can delete an organisation or manage its members; those are for people, in the panel.

### Modes

The prefix is the mode, and a request cannot override it: you change mode by using a different key.

| | `sk_live_…` | `sk_test_…` |
|---|---|---|
| Execution | real | real: the same engine, tiers and sites |
| Credits | reserved and settled | none reserved |
| Limit | your credits and plan | a daily number of jobs per plan, counted per UTC day (`429 test_quota_exceeded`) |
| Results kept | your plan's retention | 24 hours |
| Events and webhooks | `mode: "live"` | `mode: "test"` |

A schedule created with a test key runs test jobs. The daily test quota for each plan is on [Limits](https://docs.kaab.ist/concepts/limits).

### Creating a key

Create keys in the panel, or with `POST /v1/api-keys` and `{ "name", "scopes", "mode", "expires_at" }` using an `admin` key. The answer contains the key once, in `key`. Kaabist stores only a hash of it and its first twelve characters (`sk_live_ab12`), which is what every later read and the panel show. A key may create keys of its own mode only, and only an owner, an admin or an `admin` key may grant the `admin` scope.

### Rotating a key

`POST /v1/api-keys/{id}/rotate` with `{ "grace_seconds": 3600 }` issues a new key with the same name, scopes, mode and expiry, and keeps the old one working until the grace period ends, at most a day (`86400`). Move your clients to the new key inside that window. `grace_seconds: 0` cuts the old key off at once.

### Revoking and expiry

`DELETE /v1/api-keys/{id}` stops a key immediately, including one in its grace period after a rotation. A key created with `expires_at` stops at that instant; in its last week, every response to it carries `X-Kaab-Key-Expires` with the expiry, so a client can warn before it breaks.

## Conventions

### IDs

Every object has a prefixed, opaque id: `job_…`, `prg_…` (program), `pv_…` (program version), `ses_…` (session), `prp_…` (proposal), `sch_…` (schedule), `whk_…` (webhook), `key_…`, `byo_…`, `bat_…` (batch). Treat them as strings; do not parse them.

### Errors

Every non-2xx answer has the same shape:

```json
{
  "error": {
    "code": "insufficient_credits",
    "message": "this job reserves 12 credits and the organisation does not have them",
    "details": { "required": 12 },
    "request_id": "req_01J…"
  }
}
```

`code` is from a closed set, so switching on it is safe; `message` is for a person; `request_id` is what to quote when you contact support. Two codes carry structured `details` worth handling:

- `request_invalid` (400): `details.issues[]` of `{ path, message }`;
- `program_invalid` (422): `details.errors[]` and `details.warnings[]` in the lint vocabulary (`E004`), each with the path inside your program.

`not_implemented` (501) marks an operation that is documented but not yet available; the set of such operations only shrinks. The full list is under [error codes](https://docs.kaab.ist/reference/codes#errors).

### Pagination

List endpoints take `?limit=` (1 to 100, default 25) and `?cursor=`, and answer `{ "data": [...], "next_cursor": "…" }`, newest first, by creation time and then id: rows created together, such as a batch's jobs, page completely. The cursor is opaque. Pass `next_cursor` back until it is `null`. Filters such as `created_after`, `created_before` and `status` narrow the query itself, so a page is never emptied by a filter. The SDKs iterate across pages for you.

### Idempotency

`POST /v1/jobs` and `POST /v1/jobs/batch` accept `Idempotency-Key` (up to 255 characters). Within 24 hours, the same key with the same body returns the original response with `Idempotent-Replayed: true`; the same key with a different body is `409 idempotency_conflict`. Use one key per logical piece of work (an order id, a row id, a date) and retry as often as you need. The SDKs send one on every job creation.

Keys are scoped to your organisation and the key's mode: a test key and a live key that send the same `Idempotency-Key` each get a job of their own. A key used for a single job cannot be reused for a batch, or the other way round (`409 idempotency_conflict`). A retry that is recognised as a replay spends nothing: it is never refused for credits (`402`) or for the test-mode daily quota (`429`), and a retried batch returns the same `bat_` id without creating or reserving anything again.

### Waiting for a job

Job creation is asynchronous and answers `202`. To block instead of polling, send:

```http
Prefer: wait=30
```

The value is 1 to 60 seconds. You get `200` with the finished envelope if the job settles in time, and `202` with its current state if it does not. A job that parks has settled: it is in a state you can act on. The same header on `GET /v1/jobs/{id}` long-polls until the status changes; a job that has already finished is answered at once.

### Headers on every response

| Header | Meaning |
|---|---|
| `X-Kaab-Request-Id` | this request's id; send your own and Kaabist uses it |
| `X-Credits-Remaining` | the organisation's available balance, cached for up to 10 seconds |
| `X-RateLimit-Limit` | requests a minute in the bucket this request was judged by |
| `X-RateLimit-Remaining` | requests left in that bucket |
| `X-RateLimit-Reset` | the Unix second at which the bucket is full again |
| `Retry-After` | on `429` and `503`: seconds to wait before retrying |

## Worked example

With a test key in `KAAB_API_KEY`, this runs a real job against a product page and shows that it was a test job, which reserved nothing:

### TypeScript

```ts
// The key decides the mode: an sk_test_ key runs test jobs, which the live data never sees.
const probe = await kaab.jobs.create({
  url: `${site}/p/1`,
  extract: { title: "h1" },
  wait: 30,
});
console.log(`${probe.id} ran in ${probe.mode} mode`);
```

And this creates a job with an idempotency key, sends the same request again, and gets the first job back rather than a second one:

### TypeScript

```ts
// One key per order you mean to place; store it with the order on your side.
const idempotencyKey = `order-${name}`;
const request = { program: name, inputs: { sku: "HP-1037", qty: 2 } };

const first = await kaab.jobs.create({ ...request, idempotencyKey });
// A retry (after a timeout, a crash, a deploy) sends the same key and the same body…
const again = await kaab.jobs.create({ ...request, idempotencyKey });
// …and gets the same job back: nothing new is queued, no second order.
console.log(`${first.id} === ${again.id}: ${first.id === again.id}`);

const done = await kaab.jobs.wait(first.id, { timeoutMs: 120_000 });
console.log(`${done.id}: ${done.status} ${JSON.stringify(done.data)}`);
```

[Testing](https://docs.kaab.ist/guides/testing) and [Side-effect safety](https://docs.kaab.ist/guides/side-effect-safety) put both in context.

## Pitfalls

- **A live key in a test suite.** It runs live jobs and spends credits. Keep `sk_live_` keys in production secrets only.
- **Losing the key at creation.** Kaabist cannot show it again. Store it when you create it, or rotate to get a new one.
- **Reusing an idempotency key for different work.** The second request is refused with `409 idempotency_conflict`; derive the key from the thing you are doing, not from a counter.
- **Retrying `429` immediately.** Wait for `Retry-After`. The SDKs retry `408`, `429` and `5xx` with backoff.
- **Rotating with `grace_seconds: 0` before deploying the new key.** Every client still on the old key stops at once.

## Reference

- [Create an API key](https://docs.kaab.ist/reference/api/api-keys/create-api-key), [rotate](https://docs.kaab.ist/reference/api/api-keys/rotate-api-key), [revoke](https://docs.kaab.ist/reference/api/api-keys/revoke-api-key)
- [The API key object](https://docs.kaab.ist/reference/api/schemas/api-key)
- [The error response](https://docs.kaab.ist/reference/api/schemas/error-response) and [error codes](https://docs.kaab.ist/reference/codes#errors)
- [Key scopes](https://docs.kaab.ist/reference/codes#key-scopes) and [modes](https://docs.kaab.ist/reference/codes#modes)
- [Plans](https://docs.kaab.ist/reference/plans)
