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
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
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.
#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:
{
"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[]anddetails.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.
#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:
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:
// 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:
// 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 and 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
429immediately. Wait forRetry-After. The SDKs retry408,429and5xxwith backoff. - Rotating with
grace_seconds: 0before deploying the new key. Every client still on the old key stops at once.