In short
- Base:
https://web.kringl.ai/api/v1. The key goes inAuthorization: Bearer wzk_.... Every answer is JSON withok. - 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 https://web.kringl.ai/api/v1/me \
-H "Authorization: Bearer wzk_your_key_here"
{
"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:
{ "ok": false, "error": "scope_required", "scope": "runs:write" }
| Scope | What it opens |
|---|---|
sessions:read | Read a session, its messages, and the state of a run. |
sessions:write | Open a session and send a message into an existing one. |
runs:write | Open a run and cancel it. |
projects:read | See the member's list of projects. |
notify:write | Send a push message to the key's own owner, and to nobody else. |
webhooks:manage | Register, 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: trueand the fields. Failure:ok: falseanderror, 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, not403: as far as the key is concerned it does not exist. - Creation returns
201; everything else200.
| Code | error | When |
|---|---|---|
| 400 | project is required, message is required, external_id is required, title and body are required, bad_url, private_url | A required field is missing, or a webhook points at a URL that is not public http(s). |
| 401 | invalid_api_key | No key, an unknown key, or a revoked one. |
| 402 | no_credit | The workspace is out of credit. A notice in the portal; the API does not open a session on future credit. |
| 403 | scope_required, project not allowed for this member, org_admin_required | The key lacks the scope, the member has no access to the project, or the action asks for an admin. |
| 404 | not_found, project not found | The session, run or project was not found in this workspace. |
| 409 | the error message | The session could not take the message in its current state. |
| 413 | request entity too large | A body larger than 100KB. |
| 429 | reason: rate_limited, too_many_messages | Over the rate limit. See Rate limits. |
| 500 | the error message | A 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:
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:
{
"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.externalis 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 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:
| Field | Required | What |
|---|---|---|
project | yes | The project key from GET /projects. |
message | yes | The first message: what to do. Exactly as you would write it in the chat. |
title | no | A title for the session, up to 120 characters. Missing: the Kringl gives it one. |
metadata | no | A free object stored on the session. metadata.external = { provider, id } is your own id, for finding it later. |
model | no | The model family: opus, sonnet, or another the workspace allows. Missing: the workspace default. |
effort | no | How much effort: low, medium, high. Missing: the default. |
auto_run | no | true: the Kringl runs without stopping for approvals, like a run. Default false: an ordinary session. |
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
}'
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 "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 "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 -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 -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 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": { "...": "..." }
}
| Field | What |
|---|---|
outcome | done: finished; needs_info: the Kringl asked a question and waits; failed: it failed. |
summary | A sentence or two in plain language about what was done, as the chat shows it. |
pr_url, pr_number, merged | The pull request that was opened, if one was, and whether it was merged. |
filesChanged, linesAdded, linesDeleted | How many files and lines changed. |
durationMs, tokens, costUsd, credits | How long it took, how many tokens, dollars and credits it cost. |
missingInfo | On needs_info: { message, questions[] }. Answer with POST /sessions/:id/messages and the run goes on. |
error | On failed: { type, message }. |
deployFailed, frontendPending, cachePending | Flags 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 -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) andbody(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 -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"
}'
{ "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.
| Event | When |
|---|---|
session.completed | A session finished well (one opened in the chat too). |
session.needs_human | The Kringl stopped with a question and waits for a person or for a message from the API. |
session.failed | A session failed. |
session.cancelled | A session was cancelled. |
run.completed | A 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.
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.
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);
});
$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
2xxat once and do the work afterwards. - On
5xx, no answer or a network error there is one more attempt after 3 seconds. A4xxis not retried. - The same event can arrive twice. Write an idempotent receiver:
session.idandeventare 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 -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 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
| Endpoint | Scope | What |
|---|---|---|
GET /me | none | Who am I, which workspace, which key. |
GET /projects | projects:read | The projects a session can be opened in. |
POST /sessions | sessions:write | Open a session. |
GET /sessions | sessions:read | Search by external_id or status=running. |
GET /sessions/:id | sessions:read | One session, with ?messages=1 the messages too. |
POST /sessions/:id/messages | sessions:write | A message into an existing session. |
POST /runs | runs:write | Open a run. |
GET /runs/:id | sessions:read | State and result of a run. |
POST /runs/:id/cancel | runs:write | Cancel a run. |
POST /notify | notify:write | A push message to the key's owner. |
GET /webhooks, POST /webhooks, DELETE /webhooks/:id | webhooks:manage | List, 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).
| Endpoint | Scope | What |
|---|---|---|
POST /quick-answers, GET /quick-answers/:id?wait= | sessions:write / sessions:read | A 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/handoff | sessions:write / sessions:read | An investigation: read-only, a structured answer, and handoff turns it into a session that fixes. |
GET /publish/apps?q= | projects:read | The apps that can be published, by search. |
POST /publish/jobs, GET /publish/jobs/:id?log_after= | runs:write / sessions:read | Publishing 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.
// 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
sonnetatlowenough. metadata.external.idis Bob's own id; that is how the webhook knows which row it belongs to, and howGET /sessions?external_id=finds the session if Bob's server forgot.session.urlis shown to the user in the panel: a person who wants to see what is going on opens the session in the portal.
Something missing here? Write to info@wizzo.co.il and a person answers.