Your first job
This page runs one job against a shop's product page in curl, TypeScript, Python, Go or the kaab CLI, and shows the envelope it returns. It takes a test key and a few minutes, and every later page builds on the request you send here.
When to use it
Start here once you have an sk_test_ key (API keys and modes). The request is the url + extract shorthand, the simplest form a job takes. When you need a login, a click or a schedule, the same call takes a stored program instead; see Programs and versions.
Install and configure
Every client reads your key from KAAB_API_KEY, and the API address from KAAB_BASE_URL when you set it (the default is https://api.kaab.ist/v1).
export KAAB_API_KEY=sk_test_…
curl
Nothing to install. Send the key as Authorization: Bearer $KAAB_API_KEY.
TypeScript
Node 18 or later, ESM or CommonJS:
npm install @kaabist/sdk
import { Kaab } from "@kaabist/sdk";
const kaab = new Kaab(); // reads KAAB_API_KEY; or new Kaab({ apiKey, baseUrl })
Python
Python 3.11 or later, blocking (Kaab) or asyncio (AsyncKaab):
pip install kaabist
from kaabist import Kaab
kaab = Kaab() # reads KAAB_API_KEY; or Kaab("sk_test_…", base_url=...)
Go
Go 1.24 or later. The Go SDK is published as its own module:
go get github.com/kaabist/kaab-go
import "github.com/kaabist/kaab-go/kaab"
c := kaab.New("") // reads KAAB_API_KEY; kaab.WithBaseURL(...) to override
Request and response types are in the client package of the same module.
The CLI
kaab is a single binary for Linux, macOS and Windows on amd64 and arm64, built on the Go SDK. Release archives are named kaab_<version>_<os>_<arch> (.tar.gz, .zip on Windows); with a Go toolchain you can also build it from the module:
go install github.com/kaabist/kaab-go/cmd/kaab@latest
kaab auth login # paste your sk_test_ key
kaab auth login checks the key and keeps it in your operating system's keychain. KAAB_API_KEY overrides it, which is what CI should use. See The CLI.
Run the job
The job reads a shop's first product page and asks for its title and price, waiting up to 30 seconds for the result:
response=$(curl -sS --fail-with-body "${KAAB_BASE_URL:-https://api.kaab.ist/v1}/jobs" \
-H "Authorization: Bearer $KAAB_API_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: wait=30" \
-d '{
"url": "'"${SITE_URL:-https://shop.example.com}"'/p/1",
"extract": {
"title": "h1",
"price": { "selector": ".price", "type": "money", "locale": "tr", "currency": "TRY" }
}
}')
echo "$response"
// One call: create the job and hold the request up to 30 s for its result (`Prefer: wait`).
const job = await kaab.jobs.create({
url: `${site}/p/1`,
extract: {
title: "h1",
price: { selector: ".price", type: "money", locale: "tr", currency: "TRY" },
},
wait: 30,
});
if (job.status !== "succeeded") throw new Error(`job ${job.id} is ${job.status}`);
console.log(`${job.id}: ${JSON.stringify(job.data)}`);
# One call: create the job and hold the request up to 30 s for its result (Prefer: wait).
job = kaab.jobs.create(
url=f"{site}/p/1",
extract={"title": "h1", "price": {"selector": ".price", "type": "money", "locale": "tr", "currency": "TRY"}},
wait=30,
)
if job.status != "succeeded":
raise SystemExit(f"job {job.id} is {job.status}")
print(f"{job.id}: {job.data.to_dict() if job.data else None}")
// One call: create the job and hold the request up to 30 s for its result (Prefer: wait).
body, err := kaab.Body[client.CreateJobJSONBody](map[string]any{
"url": site + "/p/1",
"extract": map[string]any{
"title": "h1",
"price": map[string]any{"selector": ".price", "type": "money", "locale": "tr", "currency": "TRY"},
},
})
if err != nil {
log.Fatal(err)
}
job, err := c.Jobs.Create(ctx, body, kaab.WithWait(30))
if err != nil {
log.Fatal(err)
}
if job.Status != "succeeded" {
log.Fatalf("job %s is %s", job.ID, job.Status)
}
fmt.Printf("%s: %v\n", job.ID, *job.Data)
data=$(kaab jobs run \
--url "${SITE_URL:-https://shop.example.com}/p/1" \
--extract '{"title": "h1", "price": {"selector": ".price", "type": "money", "locale": "tr", "currency": "TRY"}}' \
--wait 30)
echo "$data"
Each client sends POST /v1/jobs with Prefer: wait=30. If the job finishes inside those 30 seconds you get the result in the same response; if it does not, you get the job as it is, and you wait for it separately.
Waiting separately
For a job that may take longer, create it without waiting and follow it to the end. Each SDK's wait helper long-polls GET /v1/jobs/{id}, so a job that is not moving costs about one request a minute:
// Or start it and wait separately: `jobs.wait` long-polls until the job ends.
const started = await kaab.jobs.create({
url: `${site}/p/2`,
extract: { title: "h1" },
});
const done = await kaab.jobs.wait(started.id, { timeoutMs: 120_000 });
console.log(`${done.id}: ${done.status} ${JSON.stringify(done.data)}`);
# Or start it and wait separately: jobs.wait long-polls until the job ends.
started = kaab.jobs.create(url=f"{site}/p/2", extract={"title": "h1"})
done = kaab.jobs.wait(started.id, timeout=120)
print(f"{done.id}: {done.status}")
// Or start it and wait separately: Jobs.Wait long-polls until the job ends.
body, _ = kaab.Body[client.CreateJobJSONBody](map[string]any{"url": site + "/p/2", "extract": map[string]any{"title": "h1"}})
started, err := c.Jobs.Create(ctx, body)
if err != nil {
log.Fatal(err)
}
done, err := c.Jobs.Wait(ctx, started.ID, kaab.WaitOptions{Timeout: 2 * time.Minute})
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s: %s\n", done.ID, done.Status)
What comes back
A finished job is one envelope. This is the one the request above returns, trimmed to the keys that matter here (ids and timings differ on your run):
{
"id": "job_01j9zqk3q7m4n5p6r7s8t9v0wx",
"object": "job",
"mode": "test",
"status": "succeeded",
"reason": null,
"program": { "id": null, "name": null, "version": null, "version_id": null },
"data": {
"title": "Kulaklık Klasik 101",
"price": { "amount": 1337.4, "currency": "TRY" }
},
"raw": null,
"warnings": [],
"meta": {
"tier": "http",
"tier_requested": "http",
"escalations": [],
"region": "eu",
"final_url": "https://shop.example.com/p/1",
"cache": "miss",
"duration_ms": 412,
"parked_ms": 0,
"steps": [
{ "path": "steps[0]", "action": "goto", "status": "ok", "duration_ms": 380, "healed": false },
{ "path": "steps[1]", "action": "extract", "status": "ok", "duration_ms": 12, "healed": false }
],
"snapshot": "/v1/jobs/job_01j9zqk3q7m4n5p6r7s8t9v0wx/snapshot"
},
"error": null,
"input_request": null,
"attention": null,
"progress": null,
"created_at": "2026-09-18T10:00:00Z",
"finished_at": "2026-09-18T10:00:01Z"
}
What to read in it:
statusis the one key to switch on. Here it issucceeded, sodatais present.datais typed:priceis a number and a currency because the field saidtype: "money"with a Turkish locale, so1.337,40 TLwas parsed rather than returned as text.programis allnullbecause the job used the shorthand rather than a stored program. A job that names a program carries its id, name and version here.meta.tierishttp: the page is server-rendered, so no browser was needed.meta.stepsshows the two steps the shorthand compiled to.warningsis empty. A field that came back empty or unreadable would be listed here, with its path.
Understanding the envelope covers every status and what it carries.
Worked example
Extend the schema with two more locator kinds the same page supports: a regular expression over an element's text, and a JSONPath over the JSON the page embeds in <script id="__NEXT_DATA__">:
{
"url": "https://shop.example.com/p/1",
"extract": {
"title": "h1",
"price": { "selector": ".price", "type": "money", "locale": "tr", "currency": "TRY" },
"sku": { "selector": ".sku", "regex": "SKU:\\s*([A-Z0-9-]+)" },
"stock": { "json": "$.props.pageProps.product.stock", "type": "integer" }
}
}
data now carries "sku": "HP-1037" and "stock": 8 beside the title and price. The job still runs at the http tier: both locators read the document that one request returned. Change the URL to /p/2 and the same schema reads the second product. When a page needs a browser instead, the job moves up a tier by itself and says so in meta.escalations; Tiers and escalation follows such a job step by step.
Pitfalls
- Reading
datawithout checkingstatus. A job that has not succeeded has nodata. A request withPrefer: waitthat ran out of time answers202with aqueuedorrunningjob. localhostURLs. Jobs run on Kaabist's workers, which cannot reach your machine or private addresses; such a request failsssrf_blocked. Use a public URL.- A
waitabove 60.Prefer: waitaccepts 1 to 60 seconds. For longer jobs, use the wait helper or a webhook.
Reference
Was this page helpful?
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.
Understanding the envelope
The one shape every job read returns, which keys each status carries, a worked example of every status, and how each SDK waits for a job.