For developers

The Kringl API

One interface, in JSON, that opens a conversation with the Kringls from your code, gives them a task, follows it and tells you when it is over. Everything the chat can do, this can too.

Last updated:

On this page
  1. 1Getting started
  2. 2Authentication
  3. 3Scopes
  4. 4Conventions and errors
  5. 5Rate limits
  6. 6Session, run and project
  7. 7Who am I, and which projects
  8. 8Sessions
  9. 9Runs
  10. 10A message to the key's owner
  11. 11Webhooks
  12. 12Every endpoint at a glance
  13. 13An end-to-end example

In short

  • Base: https://web.kringl.ai/api/v1. The key goes in Authorization: Bearer wzk_.... Every answer is JSON with ok.
  • A session is what the chat shows: open it, talk, read. A run is a session that runs on its own to the end and returns one result.
  • An HMAC-signed webhook reports every finish; no polling needed.
  • Limits: 240 calls a minute per key, 30 of them writes. Beyond that a 429 with Retry-After.

Getting started

The API lives at https://web.kringl.ai/api/v1. Every path on this page is relative to it.

A key is minted in the portal: web.kringl.ai/account/apikeys, new key, a name, copy. The key starts with wzk_, is shown only once and does not expire. It belongs to the member who minted it: what they can see, the key can see; a project closed to them is closed to the key.

A first call, which needs no scope at all:

curl
curl https://web.kringl.ai/api/v1/me \
  -H "Authorization: Bearer wzk_your_key_here"
200
{
  "ok": true,
  "user": { "id": 12, "name": "Dana" },
  "org": { "id": 3, "slug": "acme", "name": "Acme", "role": "admin" },
  "key": { "name": "crm-bridge", "prefix": "wzk_a1b2c3d4", "scopes": null }
}

scopes: null means every scope; that is what a key minted in the portal gets. A list of specific scopes narrows it (see Scopes).

Authentication

The key is sent in one of two headers, your choice:

  • Authorization: Bearer wzk_...
  • X-API-Key: wzk_...

A wrong, revoked or missing key answers 401 with { "ok": false, "error": "invalid_api_key" }.

A key is revoked on the same portal screen; from that moment every call with it is refused. Only the first 12 characters are kept on our side for identification, the rest as a hash.

A key is a password. Keep it in a vault or in the environment of your server, never in code, never in a commit, never in a user's browser. Leaked? Revoke it and mint a new one.

Scopes

Every endpoint requires one scope. A key minted in the portal carries all of them. A call without the required scope answers 403:

403
{ "ok": false, "error": "scope_required", "scope": "runs:write" }
The scopes
ScopeWhat it opens
sessions:readRead a session, its messages, and the state of a run.
sessions:writeOpen a session and send a message into an existing one.
runs:writeOpen a run and cancel it.
projects:readSee the member's list of projects.
notify:writeSend a push message to the key's own owner, and to nobody else.
webhooks:manageRegister, list and delete the workspace's webhooks. The scope and an admin role in the workspace, both.

Conventions and errors

  • The request body is JSON with Content-Type: application/json, up to 100KB.
  • Every answer is a JSON object with ok. Success: ok: true and the fields. Failure: ok: false and error, a short string meant for a program, not a person.
  • Ids are numbers. Dates are ISO 8601 in UTC.
  • A session that does not belong to the key's workspace answers 404, not 403: as far as the key is concerned it does not exist.
  • Creation returns 201; everything else 200.
The error codes
CodeerrorWhen
400project is required, message is required, external_id is required, title and body are required, bad_url, private_urlA required field is missing, or a webhook points at a URL that is not public http(s).
401invalid_api_keyNo key, an unknown key, or a revoked one.
402no_creditThe workspace is out of credit. A notice in the portal; the API does not open a session on future credit.
403scope_required, project not allowed for this member, org_admin_requiredThe key lacks the scope, the member has no access to the project, or the action asks for an admin.
404not_found, project not foundThe session, run or project was not found in this workspace.
409the error messageThe session could not take the message in its current state.
413request entity too largeA body larger than 100KB.
429reason: rate_limited, too_many_messagesOver the rate limit. See Rate limits.
500the error messageA fault on our side. Try again in a moment; if it persists, write to us with the time and the session id.

Rate limits

  • Per key: 240 calls a minute in total, 30 of them writes (POST, DELETE).
  • Per IP address: 300 calls a minute to /api/v1.

Going over answers 429 with the headers Retry-After, RateLimit-Limit and RateLimit-Remaining, and the body names the bucket you hit:

429
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 30
RateLimit-Remaining: 0

{ "ok": false, "error": "too many requests", "reason": "rate_limited", "bucket": "key_write", "retry_after": 12 }

Good client code waits the seconds in Retry-After and does not retry at once. For the state of a run prefer a webhook over polling in a loop.

Session, run and project

A project is a repository connected to the workspace, with its stages and rules. Every session belongs to one project. GET /projects returns the ones the member behind the key may work in.

A session is what the chat shows: messages back and forth, a Kringl at work, a pull request that opens. Through the API you open it, send messages into it and read it. A session has an address in the portal, url, that you can hand to a person.

A run is a session opened to run on its own to the end: nobody waits for approvals, and at the finish there is one structured result. It is the tool for a system that gives a task and wants an answer. Under the hood a run is a session, and the run id is the session id.

Credit belongs to the workspace, and a session opened through the API spends it exactly like one opened in the chat. The credits and cost_usd fields on the session say how much.

The session object

Every endpoint that returns a session returns this shape:

session
{
  "id": 2135,
  "org_id": 3,
  "project": "bob_hunter",
  "title": "The phone of listing 4821",
  "phase": "running",
  "pr_url": null,
  "pr_merged": false,
  "credits": 0.4,
  "cost_usd": 0.11,
  "files_changed": 0,
  "lines_added": 0,
  "lines_deleted": 0,
  "metadata": { "external": { "provider": "crm", "id": "4821" } },
  "created_at": "2026-09-30T08:12:41.000Z",
  "updated_at": "2026-09-30T08:12:41.000Z",
  "url": "https://web.kringl.ai/?session=2135"
}
  • phase: the stage the session is in, as the portal shows it.
  • metadata: what you sent at creation, as it was. metadata.external is the place for your own id, and what you search by later.
  • url: the address of the session in the portal, for a person who wants to look.

Who am I, and which projects

GET /me answers which member is behind the key, in which workspace and role, and the key's name and scopes (see Getting started). Needs no scope.

GET /projects (needs projects:read) returns the projects a session can be opened in. key is what you send as project:

curl
curl https://web.kringl.ai/api/v1/projects \
  -H "Authorization: Bearer $KRINGL_API_KEY"

{ "ok": true, "projects": [ { "key": "bob_hunter", "name": "Bob" }, { "key": "acme_site", "name": "Acme site" } ] }

Sessions

Open a session

POST /sessions, needs sessions:write. The body:

The body of POST /sessions
FieldRequiredWhat
projectyesThe project key from GET /projects.
messageyesThe first message: what to do. Exactly as you would write it in the chat.
titlenoA title for the session, up to 120 characters. Missing: the Kringl gives it one.
metadatanoA free object stored on the session. metadata.external = { provider, id } is your own id, for finding it later.
modelnoThe model family: opus, sonnet, or another the workspace allows. Missing: the workspace default.
effortnoHow much effort: low, medium, high. Missing: the default.
auto_runnotrue: the Kringl runs without stopping for approvals, like a run. Default false: an ordinary session.
curl
curl -X POST https://web.kringl.ai/api/v1/sessions \
  -H "Authorization: Bearer $KRINGL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "acme_site",
    "message": "The contact form on /contact does not send. Find out why and fix it.",
    "title": "Contact form",
    "metadata": { "external": { "provider": "zendesk", "id": "T-5512" } },
    "auto_run": true
  }'
201
HTTP/1.1 201 Created

{ "ok": true, "session": { "id": 2140, "project": "acme_site", "phase": "running", "...": "..." } }

Errors: 400 when a required field is missing, 404 project not found, 403 project not allowed for this member, 402 when there is no credit.

Find a session by your own id

GET /sessions?external_provider=&external_id=, needs sessions:read. Returns up to 20 sessions whose metadata.external matches, newest first. ?status=running returns the sessions running right now. Without either: 400 external_id is required.

curl
curl "https://web.kringl.ai/api/v1/sessions?external_provider=zendesk&external_id=T-5512" \
  -H "Authorization: Bearer $KRINGL_API_KEY"

{ "ok": true, "sessions": [ { "id": 2140, "...": "..." } ] }

Read a session

GET /sessions/:id, needs sessions:read. With ?messages=1 the messages come too, oldest first, each with role (user or assistant) and type.

curl
curl "https://web.kringl.ai/api/v1/sessions/2140?messages=1" \
  -H "Authorization: Bearer $KRINGL_API_KEY"

{
  "ok": true,
  "session": { "id": 2140, "...": "..." },
  "messages": [
    { "id": 90311, "role": "user", "type": "text", "content": "The contact form ...", "created_at": "..." },
    { "id": 90315, "role": "assistant", "type": "text", "content": "Found it: ...", "created_at": "..." }
  ]
}

A message into an existing session

POST /sessions/:id/messages, needs sessions:write. The body: message (required) and auto_run. The session wakes with the message as if it were typed in the chat: this is how you answer a question the Kringl asked, or add a detail.

curl
curl -X POST https://web.kringl.ai/api/v1/sessions/2140/messages \
  -H "Authorization: Bearer $KRINGL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "message": "The form is in the KRG theme, not in the old one.", "auto_run": true }'

Returns { ok: true, session }; 409 if the session could not take the message.

Runs

Open a run

POST /runs, needs runs:write. The same body as opening a session, without auto_run: a run always runs on its own. The server adds to metadata.run who asked (the key's name) and when.

curl
curl -X POST https://web.kringl.ai/api/v1/runs \
  -H "Authorization: Bearer $KRINGL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "bob_hunter",
    "message": "Open https://www.yad2.co.il/item/abc123 in the paired browser, click the phone reveal, and post the number to POST /api/details.",
    "model": "sonnet",
    "effort": "low",
    "metadata": { "external": { "provider": "bob", "id": "4821" } }
  }'

HTTP/1.1 201 Created

{ "ok": true, "run": { "id": 2141, "session_id": 2141 }, "session": { "...": "..." } }

A good run message is a playbook: numbered steps, what to open, what to click, where to send, and what not to do. A small exact task runs on sonnet at low and is over in a minute; an open task that needs investigation gets the default.

State and result

GET /runs/:id, needs sessions:read. run.status is one of running, needs_human, completed, failed, cancelled. result is null while the run is running, then the result:

curl
curl https://web.kringl.ai/api/v1/runs/2141 \
  -H "Authorization: Bearer $KRINGL_API_KEY"

{
  "ok": true,
  "run": { "id": 2141, "status": "completed", "phase": "done", "pr_url": null, "pr_merged": false, "outcome": "done" },
  "result": {
    "outcome": "done",
    "summary": "The number was read from the listing and posted to Bob.",
    "pr_url": null,
    "merged": false,
    "durationMs": 94120,
    "tokens": { "input": 41200, "output": 1830 },
    "costUsd": 0.09,
    "credits": 0.3,
    "filesChanged": 0,
    "missingInfo": null,
    "error": null
  },
  "session": { "...": "..." }
}
The fields of result
FieldWhat
outcomedone: finished; needs_info: the Kringl asked a question and waits; failed: it failed.
summaryA sentence or two in plain language about what was done, as the chat shows it.
pr_url, pr_number, mergedThe pull request that was opened, if one was, and whether it was merged.
filesChanged, linesAdded, linesDeletedHow many files and lines changed.
durationMs, tokens, costUsd, creditsHow long it took, how many tokens, dollars and credits it cost.
missingInfoOn needs_info: { message, questions[] }. Answer with POST /sessions/:id/messages and the run goes on.
errorOn failed: { type, message }.
deployFailed, frontendPending, cachePendingFlags that the deploy did not complete or that a step is left for a person; explained in summary.

needs_human is not a failure: the Kringl needs an answer. The questions are in result.missingInfo.questions; send the answer as a message into the session (run.session_id) and the run continues from where it stopped.

Cancel

POST /runs/:id/cancel, needs runs:write. Optional body { reason }. A running run stops; one that already finished answers cancelled: false with its state.

curl
curl -X POST https://web.kringl.ai/api/v1/runs/2141/cancel \
  -H "Authorization: Bearer $KRINGL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "the listing was taken down" }'

{ "ok": true, "cancelled": true, "killed": true, "run": { "id": 2141, "status": "cancelled" } }

A message to the key's owner

POST /notify, needs notify:write. A product that runs on your side tells the person who minted the key, by push, on every device of theirs: a new find, a round that ended, something that needs a look. The recipient is always the key's owner and nobody else, so a leaked key can only talk to its own owner.

  • title (required, up to 120 characters) and body (required, up to 1000 characters).
  • url: an https address opened by a tap on the message. image_url: a picture (https) shown beside it.
  • sender: the product's name as it appears in the message. Missing: the key's name.
curl
curl -X POST https://web.kringl.ai/api/v1/notify \
  -H "Authorization: Bearer $KRINGL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "New listing: 4 rooms in Florentin",
    "body": "6,900 ILS, 3rd floor with an elevator, entry in November. Score 91.",
    "url": "https://bob.example.com/listings/8123",
    "image_url": "https://bob.example.com/img/8123.jpg",
    "sender": "Bob"
  }'
200
{ "ok": true, "sent": 2, "skipped": null, "error": null }

sent is the number of devices that received it. Zero means the key's owner has no device registered for notifications in the app, not an error.

One key can send up to 60 messages an hour; beyond that 429 too_many_messages.

Webhooks

Instead of polling GET /runs/:id, register a URL of yours and Kringl POSTs to it on every event. Each registration gets a secret, shown once, that signs every delivery.

The events
EventWhen
session.completedA session finished well (one opened in the chat too).
session.needs_humanThe Kringl stopped with a question and waits for a person or for a message from the API.
session.failedA session failed.
session.cancelledA session was cancelled.
run.completedA run opened with POST /runs ended, with any outcome. Sent in addition to the session event, with run and result in the body.

What arrives

A POST with Content-Type: application/json. The body carries event, at and session (see the session object); on run.completed also run and result, exactly as in GET /runs/:id.

webhook
POST https://example.com/hooks/kringl
Content-Type: application/json
X-Wizzo-Event-Version: 1
X-Wizzo-Signature: 3f1a9c...e0b2

{
  "event": "run.completed",
  "at": "2026-09-30T08:15:02.000Z",
  "session": { "id": 2141, "org_id": 3, "project": "bob_hunter", "phase": "done", "title": "...", "pr_url": null, "pr_merged": false, "credits": 0.3, "cost_usd": 0.09, "metadata": { "external": { "provider": "bob", "id": "4821" } }, "created_at": "...", "updated_at": "..." },
  "run": { "id": 2141, "status": "completed", "outcome": "done" },
  "result": { "outcome": "done", "summary": "...", "...": "..." }
}

Verifying the signature

The X-Wizzo-Signature header is the HMAC-SHA256 of the raw body (the bytes as they arrived) with the registration's secret, in hex. Compute the same over the body you received and compare in constant time. X-Wizzo-Event-Version is 1.

Node.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
// keep the RAW body: the signature is over the bytes, not over a re-serialised object
app.post('/hooks/kringl', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto.createHmac('sha256', process.env.KRINGL_WEBHOOK_SECRET).update(req.body).digest('hex');
  const given = String(req.get('X-Wizzo-Signature') || '');
  const ok = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body);
  // answer fast, work later: Kringl waits 10 seconds and then retries once
  res.status(204).end();
  handle(event).catch(console.error);
});
PHP
$raw      = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, getenv('KRINGL_WEBHOOK_SECRET'));
$given    = $_SERVER['HTTP_X_WIZZO_SIGNATURE'] ?? '';
if (!hash_equals($expected, $given)) { http_response_code(401); exit; }
$event = json_decode($raw, true);
http_response_code(204);

Redelivery

  • Kringl waits up to 10 seconds for an answer. Answer 2xx at once and do the work afterwards.
  • On 5xx, no answer or a network error there is one more attempt after 3 seconds. A 4xx is not retried.
  • The same event can arrive twice. Write an idempotent receiver: session.id and event are the key.

Registering through the API

Needs webhooks:manage and an admin role in the workspace (otherwise 403 org_admin_required). The URL must be public http(s): a private address or localhost is refused with 400 private_url. A missing events means every event.

curl
curl -X POST https://web.kringl.ai/api/v1/webhooks \
  -H "Authorization: Bearer $KRINGL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/hooks/kringl", "events": ["run.completed", "session.needs_human"] }'

HTTP/1.1 201 Created

{ "ok": true, "webhook": { "id": 7, "url": "https://example.com/hooks/kringl", "events": ["run.completed", "session.needs_human"], "secret": "whs_...shown once..." } }
curl
curl https://web.kringl.ai/api/v1/webhooks -H "Authorization: Bearer $KRINGL_API_KEY"

{ "ok": true, "webhooks": [ { "id": 7, "url": "https://example.com/hooks/kringl", "events": ["run.completed", "session.needs_human"], "...": "..." } ],
  "events": ["session.completed", "session.failed", "session.needs_human", "session.cancelled", "run.completed"] }

curl -X DELETE https://web.kringl.ai/api/v1/webhooks/7 -H "Authorization: Bearer $KRINGL_API_KEY"

{ "ok": true, "removed": true }

The secret appears only in the answer of the registration. Lost it? Delete the registration and register again.

Every endpoint at a glance

The endpoints of /api/v1
EndpointScopeWhat
GET /menoneWho am I, which workspace, which key.
GET /projectsprojects:readThe projects a session can be opened in.
POST /sessionssessions:writeOpen a session.
GET /sessionssessions:readSearch by external_id or status=running.
GET /sessions/:idsessions:readOne session, with ?messages=1 the messages too.
POST /sessions/:id/messagessessions:writeA message into an existing session.
POST /runsruns:writeOpen a run.
GET /runs/:idsessions:readState and result of a run.
POST /runs/:id/cancelruns:writeCancel a run.
POST /notifynotify:writeA push message to the key's owner.
GET /webhooks, POST /webhooks, DELETE /webhooks/:idwebhooks:manageList, register and delete webhooks.

More endpoints

Three more families live in the same API, for those who need them. They are documented here briefly; their shape is the one of sessions and runs (the same codes, the same ok).

More endpoints
EndpointScopeWhat
POST /quick-answers, GET /quick-answers/:id?wait=sessions:write / sessions:readA quick answer: a question about a project's code without a full session. ?wait= in seconds waits for the answer in the same call.
POST /investigations, GET /investigations/:id, POST /investigations/:id/handoffsessions:write / sessions:readAn investigation: read-only, a structured answer, and handoff turns it into a session that fixes.
GET /publish/apps?q=projects:readThe apps that can be published, by search.
POST /publish/jobs, GET /publish/jobs/:id?log_after=runs:write / sessions:readPublishing a version to the stores: repo (required), platforms, stage, track, version; log_after returns the log from that line on.

An end-to-end example

The case this document was born from: Bob, a flat hunter of one of our customers, shows listings from a classifieds site. A click on "get me the phone" opens a run in Kringl, which opens the listing in the customer's paired browser, clicks the number reveal and posts it back to Bob's API. The webhook closes the loop.

Node.js
// 1. the user clicks "get me the phone" in your panel: open a run
const res = await fetch('https://web.kringl.ai/api/v1/runs', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${process.env.KRINGL_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    project: 'bob_hunter',
    message: playbookFor(listing),          // an exact, numbered brief: what to open, what to click, where to post
    model: 'sonnet', effort: 'low',          // a small job on the cheap gear
    metadata: { external: { provider: 'bob', id: String(listing.id) } }
  })
});
const { run, session } = await res.json();
await db.query('UPDATE listings SET phone_run_id = ?, phone_session_url = ? WHERE id = ?', [run.id, session.url, listing.id]);

// 2. show the user "being fetched now" with a link to session.url

// 3. the run posts the phone straight into your own API from inside the browser session,
//    and run.completed arrives on your webhook: reconcile by metadata.external.id
app.post('/hooks/kringl', verifySignature, async (req, res) => {
  const ev = req.event;
  if (ev.event !== 'run.completed') return res.status(204).end();
  const id = ev.session.metadata?.external?.id;
  if (ev.result?.outcome !== 'done') await db.query('UPDATE listings SET phone_error = ? WHERE id = ?', [ev.result?.summary || ev.result?.error?.message || 'failed', id]);
  res.status(204).end();
});
  • The message is an exact playbook, not a general request: that is what makes sonnet at low enough.
  • metadata.external.id is Bob's own id; that is how the webhook knows which row it belongs to, and how GET /sessions?external_id= finds the session if Bob's server forgot.
  • session.url is shown to the user in the panel: a person who wants to see what is going on opens the session in the portal.

Mint a key

Something missing here? Write to info@wizzo.co.il and a person answers.