# Sessions

Staying signed in across jobs: what a session stores, how jobs bind and lock it, how Kaabist checks it, what happens when it expires, and refresh programs.

A session is a stored, encrypted browser storage state that lets jobs start already signed in to a site. You create it once, from a first run or by uploading a state, and every job you bind to it skips the login, until the site expires it and a refresh program or a person signs in again.

## When to use it

Use a session for any program that works behind a login and runs more than once: a scheduled export, a report read every hour, a batch of lookups in a portal. Without one, every job signs in, which is slower, costs browser time, and on many sites triggers a one-time code or a "new device" warning each time. For a single run, a program that signs in without saving a session is enough.

## What a session is

```json
{
  "id": "ses_01J…",
  "object": "session",
  "name": "portal-login",
  "origins": ["https://portal.example.com"],
  "status": "valid",
  "concurrency": "exclusive",
  "refresh": { "program": "portal-report", "has_inputs": true },
  "last_verified_at": "2026-09-20T09:00:00Z",
  "last_used_at": "2026-09-20T11:42:00Z",
  "locked_by": null,
  "created_at": "…", "updated_at": "…"
}
```

`status` is `valid`, `expired` or `unknown`. `origins` scopes which sites the session's cookies may be sent to. The storage state itself is **write-only**: the object has no field for it. You can replace it and delete it; nothing (not the API, not the panel, not a support request) reads it back. It is encrypted with your organisation's own data key and exists in the clear only inside the worker running your job, for the length of that job.

## Creating a session

There are two ways.

**From a job.** A `checkpoint` step with `save_session` writes the browser's storage state to the named session, creating it if it does not exist and merging into it if it does:

```json
{ "checkpoint": { "name": "logged_in", "save_session": "portal-login" } }
```

This is the usual way: the program signs in once, and the state it ends up with is what later jobs start from.

**From a state you already have.** `POST /v1/sessions` with a Playwright `storageState`:

```json
{
  "name": "portal-login",
  "storage_state": { "cookies": [], "origins": [] },
  "concurrency": "exclusive",
  "refresh": { "program": "portal-report", "inputs": { "email": "dev@example.com" } }
}
```

`origins` is derived from the state when you do not send it. `PATCH /v1/sessions/{id}` changes `name`, `concurrency` or `refresh`, or replaces `storage_state`.

## Binding a session to jobs

A job binds a session by name, with `session` in the job body or `options.session` in the program:

```json
{ "options": { "session": "portal-login", "resume_from_session": true } }
```

The session is loaded before the first step. With `resume_from_session: true`, a job whose session is valid skips straight to the step after the `checkpoint` that saved it: the login steps run only when they are needed. A schedule binds a session the same way, through the program or the schedule's `options`, so every fire starts signed in.

### Two halves of being signed in

Being signed in is cookies **and** `localStorage`, and the tiers see them differently:

- the **browser** tiers load and save both, so a session saved by a browser job is complete;
- the **http** tier carries the cookies only and never writes a session back. A program that saves a session needs a browser, and its `checkpoint … save_session` gives it one.

A session saved by a browser job therefore works at the http tier afterwards, but not the other way round.

## Locking

Two jobs signed in as the same person at the same time is how sites decide you are a robot, and on a site that allows one session at a time each job logs the other out. So a job holding a session holds a **lease** on it:

- `exclusive` (the default): one job at a time. Another job bound to the session waits in the queue with `meta.blocked_reason: "session_locked"`, and a job that still cannot take the lease after a minute fails `session_locked_timeout`.
- `shared`: opt in for sites you know tolerate parallel sign-ins.

A job releases its lease when it ends, whichever way it ends. `locked_by` names the job holding it, and `DELETE /v1/sessions/{id}` answers `409 session_locked` (with `details.job_id`) while one does.

## Checking that a session works

Sites expire sessions on their own schedule and never say so. Kaabist finds out in two places:

1. **Before the job.** If nobody has checked the session in the last hour, a probe loads the root of the session's first origin with it, at the http tier, and reads the answer: a recognised sign-in page means `expired`; a page that cannot be judged means `unknown`, and the job runs and finds out. A session already known to be expired is not probed. A site whose signed-in page only appears after its JavaScript runs should set `options.session_probe: "browser"` on the program, which probes with a browser page load instead.
2. **During the job.** A step that lands on a sign-in page (the program's `login_page` landmark, or a recognised login form) is classified as an expired session rather than a broken program.

`POST /v1/sessions/{id}/verify` runs the probe now and answers `{ "status", "checked_at" }`. It costs 1 credit, or 5 where a browser probes.

## Expiry

When a session is found expired, its `status` becomes `expired`, a `session.expired` event is sent once, and the job does what the program's `policies.on_session_expired` says:

| `on_session_expired` | What the job does |
|---|---|
| `refresh` (default) | runs the session's refresh program and carries on; with no refresh program, or one that cannot finish, parks as `session_expired` |
| `park` | parks as `needs_attention` with reason `session_expired`, for a person to sign in |
| `fail` | fails with `session_expired` |

A parked `session_expired` job is resolved like any other park: a person [takes over](https://docs.kaab.ist/panel/takeover) and signs in, or you share a [hosted resolve page](https://docs.kaab.ist/guides/hosted-resolve) with whoever holds the account. See [Human in the loop](https://docs.kaab.ist/concepts/human-in-the-loop).

## Refresh programs

A refresh program is an ordinary program that knows how to sign in and ends with a `checkpoint` that saves the session. Name it in the session's `refresh`.

When a session expires mid-job, Kaabist runs the refresh program **inside that job, in the same browser**, so the page your program was on is the page that ends up signed in, and then retries the step that failed. Its steps appear in the job's timeline under `session_refresh/`. If the site asks for a one-time code, the refresh stops and **your job parks** for it as `awaiting_input`, exactly as an `await_input` step in your own program would; answering it resumes the refresh, not the whole program.

`POST /v1/sessions/{id}/refresh` runs the refresh program on demand as an ordinary job and answers `202` with it.

### Credentials

Storing a password is optional, discouraged, and off unless you turn it on. Kaabist keeps a refresh program's input only when all three are true:

1. your organisation has enabled secret storage in its settings;
2. the refresh program declares the input as `{ "type": "secret", "store": true }`;
3. you supplied it in the session's `refresh.inputs`.

Setting a session's refresh inputs is recorded in your organisation's audit log.

Offering a secret that cannot be stored is refused with `422 secret_storage_disabled` rather than dropped, so you are never told a password was saved when it was not. A session with no stored credentials cannot refresh unattended: it parks and a person signs in, which is the mode we recommend.

## What is kept, and where

| | |
|---|---|
| Storage state | encrypted with your organisation's data key; write-only |
| Stored refresh inputs | the same, and only inputs marked `store: true` |
| Logs, snapshots, screenshots, webhooks | cookie names at most, never values |
| Deleting a session | removes the row and its state; an organisation data deletion removes every session |

## Worked example

The site at `https://portal.example.com` signs in with an e-mail and password, asks for a six-digit code, and sets a `sid` cookie. A program for it fills the form, parks on `await_input` for the code, then saves the session:

```json
{ "id": "save", "checkpoint": { "name": "logged_in", "save_session": "portal-login" } }
```

It binds `options.session: "portal-login"` with `resume_from_session: true`, and sets `policies.on_session_expired: "refresh"`.

**First run.** No session exists yet, so the job runs every step: it signs in, parks for the code, takes your answer, and at the `save` checkpoint creates `portal-login`. The envelope's `meta.session` is `{ "id": "ses_…", "name": "portal-login", "saved": true }`.

**Uploading instead.** If you already hold a signed-in state, create the session directly and run the program against it:

### TypeScript

```ts
// Storage state is write-only: Kaabist encrypts it and never returns it.
const session = await kaab.sessions.create({
  name,
  storage_state: {
    cookies: [
      {
        name: "sid",
        value: "example-session-cookie",
        domain: new URL(site).hostname,
        path: "/",
        expires: -1,
        httpOnly: true,
        secure: false,
        sameSite: "Lax",
      },
    ],
    origins: [],
  },
  origins: [new URL(site).origin],
  concurrency: "exclusive", // one job at a time inside it
});
console.log(`${session.id}: ${session.name} is ${session.status}`);
```

**Later runs.** The session is valid, so the job jumps past the login to the report and never parks. Bind the same program to a schedule and every fire starts at the report:

### TypeScript

```ts
const job = await kaab.jobs.create({
  url: `${site}/api/echo`,
  extract: { sid: { network: { url: "**/api/echo", json: "$.cookies.sid" } } },
  session: name, // the job starts with the session's cookies and storage
  options: { prefer_tier: "browser" }, // a browser, as a signed-in person would use
  wait: 30,
});
console.log(`${job.id} ran in ${job.meta.session?.name}: sid=${JSON.stringify(job.data?.sid)}`);
```

**Expiry.** When the site expires the cookie, the next job's report page redirects to the sign-in form. The step is classified as an expired session, `portal-login` becomes `expired`, `session.expired` is sent, and the refresh program signs in again inside the job, parking for a new code if the site asks for one. With `on_session_expired: "park"` instead, the job parks as `needs_attention: session_expired` for a person.

## Pitfalls

- **Two jobs, one account, `shared` concurrency.** On a site that allows one sign-in at a time, each job logs the other out. Keep `exclusive` unless you know the site tolerates it.
- **A session for an http-only program.** The http tier never saves a session; a program that only reads with cookies needs the session created by a browser job or uploaded.
- **Storing passwords by default.** Prefer `park` or a refresh program without stored secrets, and let a person sign in when the session expires.
- **Expecting to read the state back.** You cannot. Keep your own copy if you need one, or create the session again from a job.

## Reference

- [Create a session](https://docs.kaab.ist/reference/api/sessions/create-session), [update](https://docs.kaab.ist/reference/api/sessions/update-session), [verify](https://docs.kaab.ist/reference/api/sessions/verify-session), [refresh](https://docs.kaab.ist/reference/api/sessions/refresh-session)
- [The session object](https://docs.kaab.ist/reference/api/schemas/session)
- [`checkpoint`](https://docs.kaab.ist/reference/dsl/actions/checkpoint), [`set-session`](https://docs.kaab.ist/reference/dsl/actions/set-session), [program options](https://docs.kaab.ist/reference/dsl/options), [policies](https://docs.kaab.ist/reference/dsl/policies)
- [Events](https://docs.kaab.ist/reference/events) (`session.expired`) and [error codes](https://docs.kaab.ist/reference/codes#errors)
- [`kaab sessions create`](https://docs.kaab.ist/reference/cli/kaab-sessions-create)
