# Browser sessions

A **session** opens a payer's [browser profile](/docs/profiles) without an
intent, for you to drive step by step. Use one to sign a customer in to a shop
once, so their later intents find them signed in, or to see what a profile
holds. Nothing decides for you in a session: you send each step.

<Callout type="warning">
A session never pays and never places an order. Buying is what
[intents](/docs/intents) are for, with their spending caps and receipts.
</Callout>

## Signing a customer in

Many shops want an account to check out, and their sign-in asks for things
only your customer knows: a password, a code sent to their phone. The easy
way is the [sign-in link](/docs/intents#sign-in-links) an intent waits with
when the shop asks: you send it, your customer signs in on our page, and you
build nothing. A session lets your app run the sign-in itself instead, and
pass those on.

1. **Open** a session on the customer's profile, at the shop's sign-in page:

   ```bash
   curl https://api.openintents.io/v1/sessions \
     -H "Authorization: Bearer $OPENINTENTS_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{ "customer": "cus_42", "start_url": "https://www.example-shop.com/signin" }'
   ```

   It answers `201` with the session `queued`. It is `open` within seconds:
   [follow its events](/docs/events) or retrieve it.

2. **Look** at the page with a `snapshot`. Each part you can act on has a
   `ref`, such as `e7` for the email box.
3. **Act**: type the email into `e7`, click Continue, type the password, then
   the code the shop sent. Show your customer the page with a
   [screenshot](#screenshots) and pass on what they type.
4. **Release** the session. The profile keeps what the session changed: the
   customer is signed in for their next intents at that shop.

Your app builds the screen where the customer types; their password passes
through your app on its way. Don't log it. OpenIntents keeps what a session
types only until the command runs (see [typed text](#typed-text)).

## The session object

```json
{
  "id": "ses_51f0e2",
  "object": "session",
  "profile": "prf_7c1d04e2a9b3",
  "customer": "cus_42",
  "status": "open",
  "test": false,
  "start_url": "https://www.example-shop.com/signin",
  "url": "https://www.example-shop.com/signin",
  "title": "Sign in",
  "created_at": "2026-10-13T08:30:00Z",
  "opened_at": "2026-10-13T08:30:04Z",
  "expires_at": "2026-10-13T08:45:04Z",
  "closed_at": null,
  "close_reason": null
}
```

| Status | Meaning |
| --- | --- |
| `queued` | Waiting for a browser. |
| `opening` | The browser is starting on the profile. |
| `open` | Ready for commands. |
| `closing` | Released (or at a limit): the browser closes and the profile is saved. |
| `closed` | Over; `close_reason` says why. |
| `failed` | Over, because its browser stopped; the profile keeps what was last saved. |

`close_reason` is `released`, `idle`, `timed_out`, `data_limit`,
`browser_lost` or `error`. Others may appear.

## Commands

<Endpoint method="POST" path="/v1/sessions/{id}/commands" />

Commands run one at a time, in the order sent. The request waits up to 20
seconds: `200` with the result, or `202` with the command still `pending`,
which you follow with `GET /v1/sessions/{id}/commands/{command_id}`. A retry
with the same `Idempotency-Key` returns the same command instead of running it
again.

| Command | Body | Returns |
| --- | --- | --- |
| Navigate | `{ "type": "navigate", "url": "https://..." }` | The page's `url` and `title`. |
| Snapshot | `{ "type": "snapshot" }` | `snapshot`: the page's parts, each you can act on with a `ref`. |
| Act | `{ "type": "act", "ref": "e7", "action": "type", "text": "ada@example.com" }` | The page after it. |
| Read | `{ "type": "read", "selector": "#order-history" }` | `text`: the page's text, or one element's. |

`act` takes an `action`: `click`, `type` (with `text`), `select` (with
`value`) or `press` (with `key`, such as `Enter`, `Tab` or `Shift+Tab`).

```json
{
  "id": "cmd_9a2c",
  "object": "session_command",
  "type": "act",
  "status": "done",
  "url": "https://www.example-shop.com/signin/password",
  "title": "Sign in",
  "error": null,
  "created_at": "2026-10-13T08:30:12Z",
  "completed_at": "2026-10-13T08:30:13Z"
}
```

A few rules keep acts on the element you meant:

- An `act` names a `ref` from the **latest snapshot**. After a `navigate`, and
  after any `act` but a `type` without line breaks, take a new snapshot
  first: the page may have changed under the old refs. Otherwise the act
  fails with `not_found`, as it does when the page moved on since the
  snapshot.
- `type` goes only into text fields (`not_actionable` otherwise). A frame's
  elements have refs of their own: act on those, not on the frame.
- `text` may hold line breaks but no other control characters, and `key` is
  one key, alone or with Shift or Control. Anything else answers `400`.

## What a session refuses

A command a session will not run comes back `refused`, with the reason in
`error`. `payment_not_allowed` is for:

- a control that places an order or pays (Place order, Pay, Buy now,
  Subscribe, Donate and the like), and on a checkout page also a bare
  Continue, Confirm, Submit or Next;
- any action on a payment card's field;
- typing what looks like a card number;
- Enter, pressed or typed as a line break, at a checkout or where an order
  button shows.

A `navigate` goes only to public `https` URLs on the default port
(`url_not_allowed`). Commands that could not run come back `failed`:

| `error.code` | Meaning |
| --- | --- |
| `not_found` | No such `ref` in the latest snapshot, or the page changed since. |
| `not_actionable` | The element can't take that action. |
| `timed_out` | The page took too long. |
| `browser_error` | The browser could not do it. |
| `browser_lost` | The browser stopped; the session `failed` with it. |
| `session_closed` | The session ended before the command finished. |

## Screenshots

<Endpoint method="GET" path="/v1/sessions/{id}/screenshot" />

A JPEG of the page, taken now. It shows the payer's signed-in pages, so it is
sent `Cache-Control: no-store` and never kept. At most 2 screenshots of a
session wait at once (`429`, `too_many_commands`).

## Typed text

What a `type` command types (a password, say) is kept, sealed, only until the
browser takes the command. Results and events never return it: events show
`typed_chars`, how many characters were typed, and a snapshot never includes
what text fields hold. The page itself may still show it, in a screenshot for
example.

## Limits

- An account can have 2 sessions that have not ended at once
  (`409`, `session_limit`).
- A session closes `max_minutes` after it opens (at most and by default 15),
  after 5 minutes with no command sent or run (`idle`), or once its pages use
  more data than a session may (`data_limit`). Screenshots and event streams
  don't keep it open.
- At most 20 commands wait in a session at once (`429`, `too_many_commands`).

These may change.

## Endpoints

<Endpoint method="POST" path="/v1/sessions" />

`profile` or `customer` (not both; neither opens your account's own),
`start_url`, `max_minutes` and `webhook_url` (an `https` URL for this
session's [events](/docs/webhooks#one-intent-or-session)), all optional.
`409 profile_busy` when an intent or another session uses the profile.

<Endpoint method="GET" path="/v1/sessions?status=open&customer=cus_42" />

<Endpoint method="GET" path="/v1/sessions/{id}" />

<Endpoint method="POST" path="/v1/sessions/{id}/release" />

Closes the browser and saves the profile: `closing`, then `closed`
(`released`). Releasing one that already ended returns it unchanged.

<Endpoint method="GET" path="/v1/sessions/{id}/events" />

The session's timeline, as a page or a [live stream](/docs/events).
[Webhooks](/docs/webhooks) can tell you when a session opens, closes or
fails.
