> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tilion.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Driving a session over HTTP

These endpoints control the session's current tab over HTTP. Calls are serialized per session. A competing call waits up to about three seconds, then returns `409 session_busy` if the previous operation is still running.

Each call counts as one action under the drive rate limit. By default, calls use the tab shown in the live viewer. The `navigate` and `tabs` endpoints support selecting another tab.

A main CDP client, native Playwright client, or VNC client holds the session, so REST drive calls and fetch return `409 session_driven_by_client`. Disconnect that client before driving over REST. [CDP observers](https://docs.tilion.dev/sessions/playwright-cdp.md#multiple-cdp-clients) do not hold the session and can remain connected. Coordinate their commands with REST actions because all clients share the browser.

| Status | `detail.error` | Handling |
| - | - | - |
| 409 | `session_busy` | Another call or job is driving the session. `detail` includes `job_id` (nullable), `kind` and `held_for_s`. Poll the job when provided, or retry after the current call ends. |
| 409 | `session_driven_by_client` | Disconnect the main CDP, native Playwright, or VNC client before making REST drive calls. CDP observers can remain connected. |

## POST /v1/session/\{session\_id}/navigate

Open a URL in a tab that stays open, so later `act`, `elements`, `screenshot` calls and the live view work on it. The page is checked for walls and challenges; when the landing is blocked or off target the API tries to recover inside this session before answering. Recovery here stays on the session's own exit; `POST /v1/fetch` is the route that may escalate to stronger exits, so use `fetch` for a protected page and `navigate` for an interactive flow on a page you can already reach.

Request:

| Field | Type | Default | Meaning |
| - | - | - | - |
| `url` | string, max 4096, required | | `http` or `https` only. Private, loopback and link-local addresses answer 400. |
| `tab_id` | string | newest tab | The tab to drive, from `GET /v1/session/{id}/tabs`. Unknown ids answer 404 `No such tab`. |
| `expect_url` | regular expression, max 512 | none | The landed URL must match it; otherwise the result is `redirected_off_target` with `error: "expect_url_mismatch"`. An invalid expression answers 422. Without it, a deep link that is sent back to its entry page is reported off target with `error: "collapsed_to_entry"`. |

`expect_text`, `no_results_url`, `wait_ms`, `settle_ms` and `wait_for_selector` belong to `POST /v1/fetch`; sent here they are ignored.

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/navigate \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/"}'
```

Response (200):

```json theme={"dark"}
{
  "session_id": "sess_3d245e56b86d4bc8ae32095b644117d4",
  "final_url": "https://example.com/",
  "title": "Example Domain",
  "status": "ok",
  "http_status": 200,
  "error": null,
  "failure_type": null,
  "cause": null,
  "self_healed": false,
  "recovery": null,
  "detected": null,
  "held": null,
  "requested_url": "https://example.com/",
  "redirected": false
}
```

Response fields:

| Field | Type | Meaning |
| - | - | - |
| `final_url`, `title` | string | Where the tab is now, and its title (empty string or null when the page has none). |
| `status` | string | `ok`: the page you asked for is open. `redirected_off_target`: a page opened, but not the one asked for (`error` says why). `empty`: the origin answered 404 or 410 (`error` `not_found` or `gone`). `blocked`: a wall or challenge refused the page and recovery did not clear it. `error`: the navigation itself failed (`error` names it, for example `navigation_failed` or `deadline`). `no_results`: the site answered a search with no results and sent the visitor on. |
| `http_status` | integer or null | The origin's status for the main document. |
| `error` | string or null | A slug when `status` is not `ok`: `expect_url_mismatch`, `collapsed_to_entry` (with `redirected_off_target`); `not_found`, `gone` (with `empty`); `time_exhausted` (with `blocked`: recovery ran out of budget); `navigation_failed`, `deadline` (with `error`). |
| `failure_type`, `cause` | string or null | The failure class and what failed, when something did (for example `TimeoutError`, `page_unusable`, `observation_failed`). |
| `self_healed` | boolean | True when the page was blocked at first and cleared by recovery. |
| `recovery` | object or null | What was tried: `attempts`, `actions[]` with `action`, `result`, `reason`, `elapsed_ms`, and `ladder` with `outcome`, `attempts`, per-try `rung`, `result`, `elapsed_ms`, `reason`, and `budget` (`kind`, `total_s`, `elapsed_s`, `spent` by phase). Null when nothing needed recovering. |
| `detected` | object or null | The wall found on the first page: `family` (vendor), `category`, `mechanism`. Null on a healthy page. |
| `held` | object or null | Set when the origin served an error page with text that was not delivered: `http_status`, `text_chars`, `recovery`, `reason`. |
| `requested_url`, `redirected` | string, boolean | What you asked for, and whether the browser ended somewhere else. |

An off-target landing (`expect_url` did not match):

```json theme={"dark"}
{
  "session_id": "sess_3d245e56b86d4bc8ae32095b644117d4",
  "final_url": "https://example.com/",
  "title": "Example Domain",
  "status": "redirected_off_target",
  "http_status": 200,
  "error": "expect_url_mismatch",
  "failure_type": null,
  "cause": null,
  "self_healed": false,
  "recovery": {
    "driver": "data_plane",
    "attempts": 0,
    "actions": [],
    "ladder": {
      "outcome": "exhausted",
      "attempts": 1,
      "rungs": [
        {"rung": "eip", "result": "still_bounced", "elapsed_ms": 5419},
        {"rung": "isp", "result": "unavailable", "elapsed_ms": 0, "reason": "navigate_keeps_the_callers_session"},
        {"rung": "residential", "result": "unavailable", "elapsed_ms": 0, "reason": "navigate_keeps_the_callers_session"}
      ],
      "exhausted_reason": "no_tier",
      "budget": {"kind": "sync", "total_s": 55.0, "elapsed_s": 5.6, "spent": {"heal": 0.15, "chain": 5.42}}
    }
  },
  "detected": null,
  "held": null,
  "requested_url": "https://example.com/",
  "redirected": false
}
```

The `isp` and `residential` rungs read `unavailable` with `navigate_keeps_the_callers_session`: a navigate never moves your session to another exit, because the tab, cookies and live view have to stay yours. `POST /v1/fetch` is the route that may climb to stronger exits.

A page the origin does not have (`recovery` trimmed):

```json theme={"dark"}
{
  "final_url": "https://example.com/this-page-does-not-exist",
  "title": null,
  "status": "empty",
  "http_status": 404,
  "error": "not_found",
  "cause": "page_unusable",
  "detected": {"family": "cloudflare", "category": "client_error", "mechanism": "client_error", "source": "data_plane"},
  "requested_url": "https://example.com/this-page-does-not-exist",
  "redirected": false
}
```

A redirect that collapsed to an entry page (`https://httpbin.org/cookies/set/sessionid/abc123` lands on `/cookies`): 200, `status: "redirected_off_target"`, `error: "collapsed_to_entry"`, `redirected: true`, `final_url: "https://httpbin.org/cookies"`. The page is open and usable (the cookie was set); the status tells you the URL moved.

Errors:

| Status | Body | When |
| - | - | - |
| 400 | `unsafe url: host '...' resolves to non-public address ...` | Private, loopback or link-local target; also `unsafe url: host 'localhost' is blocked`. |
| 402 | `insufficient_credits`, `quota_exceeded` | Out of credits or allowance. |
| 404 | `No such tab` | Unknown `tab_id`. |
| 404 / 410 | session answers | Unknown or ended session. |
| 409 | `session not ready (state=...)` | The browser is still booting or closing. |
| 409 | `session_busy` | Another call is still driving after the short wait budget. Includes `job_id`, `kind` and `held_for_s`. |
| 409 | `session_driven_by_client` | A main CDP, native Playwright, or VNC client holds the session. Disconnect it first. CDP observers do not hold the session. |
| 410 | `session's worker is unreachable` | The session's server cannot be reached. Create a new session. |
| 422 | validation list | Missing `url`, `url` over 4096, invalid `expect_url` (`expect_url is not a valid regular expression: ...`). |
| 429 | drive throttle | Over the drive calls per minute limit. |
| 502 | `Data Plane tab read failed` | The tab list could not be read while checking `tab_id`. |

Notes: a plain page answers in about half a second. A navigation that needs recovery runs up to 55 seconds (`budget.total_s`) and then answers `status: "error"`, `error: "deadline"`, `failure_type: "TimeoutError"`.

## POST /v1/session/\{session\_id}/act

A human-style input on the current page: click, type, fill, press a key, scroll, set the clipboard, or wait for an element. Use `POST .../elements` first to get a selector that resolves to one element.

Request:

| Field | Type | Meaning |
| - | - | - |
| `action` | `click`, `type`, `fill`, `press`, `scroll`, `set_clipboard`, `wait_for` (required) | What to do. Anything else answers 422. |
| `selector` | CSS selector, max 2048 | The target for `click`, `fill`, `type` (optional for `type`), `wait_for`. |
| `by_text` | string, max 512 | `click` the clickable element whose visible text matches (buttons, links, inputs). |
| `text` | string, max 20000 | The value for `fill`, `type` and `set_clipboard`. Never logged or echoed. |
| `secret` | boolean | On `fill`: the value is a password, token or one-time code. A password input is treated this way without the flag. |
| `key` | string, max 64 | For `press`: `Enter`, `Tab`, `Escape`, `ArrowDown`, `Control+V`, and so on. |
| `dy` | integer, -200000 to 200000 | For `scroll`: pixels down (negative scrolls up). Default 800. |

`fill` replaces the field's value; `type` types into the selector, or into the focused element when no selector is given. `wait_for` waits for the selector to appear (about 10 seconds, then 404). `set_clipboard` puts text on the session's clipboard so a person on the live view can paste it.

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/act \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"action": "fill", "selector": "input[name=custname]", "text": "Ada Lovelace"}'
```

Response (200):

```json theme={"dark"}
{"ok": true, "action": "fill", "error": null}
```

Other actions:

| Request body | Response |
| - | - |
| `{"action": "fill", "selector": "input[name=custtel]", "text": "555-0100", "secret": true}` | `{"ok": true, "action": "fill", "error": null}` |
| `{"action": "type", "selector": "input[name=custemail]", "text": "ada@example.com"}` | `{"ok": true, "action": "type", "error": null}` |
| `{"action": "press", "key": "Tab"}` | `{"ok": true, "action": "press", "error": null}` |
| `{"action": "scroll", "dy": 300}` | `{"ok": true, "action": "scroll", "error": null}` |
| `{"action": "set_clipboard", "text": "paste me"}` | `{"ok": true, "action": "set_clipboard", "error": null}` |
| `{"action": "wait_for", "selector": "textarea[name=comments]"}` | `{"ok": true, "action": "wait_for", "error": null}` |

Response fields: `ok` (boolean), `action` (echo), `error` (null, or a slug when `ok` is false with status 200, for example `incomplete_observation` when the page could not be observed after the action).

Errors (the session stays alive on all of them):

| Status | `error` | When |
| - | - | - |
| 400 | `click_requires_selector_or_by_text` | `click` with neither. |
| 400 | `fill_requires_selector`, `press_requires_key`, `wait_for_requires_selector` | Missing argument. |
| 400 | `clipboard_write_unavailable` | The page does not allow clipboard writes (a browser error page, a page without a secure origin). |
| 404 | `element_not_found` | The selector matched nothing (also `wait_for` after its wait). |
| 404 | `no_clickable_element_matching_text` | `by_text` matched no clickable element. Labels are not clickable targets. |
| 409 | `no_page_loaded` | No page has been opened in the session yet. |
| 409 | `session_busy` | Another call or job is driving the session. Retry when it ends. |
| 409 | `session_driven_by_client` | A main CDP, native Playwright, or VNC client holds the session. Disconnect it first. CDP observers do not hold the session. |
| 422 | validation list | Unknown `action`, `text` over 20000, `dy` out of range. |

As returned:

```jsonl theme={"dark"}
{"detail": {"ok": false, "action": "click", "error": "element_not_found"}}
{"detail": {"ok": false, "action": "click", "error": "click_requires_selector_or_by_text"}}
{"detail": {"ok": false, "action": "fill", "error": "fill_requires_selector"}}
{"detail": [{"type": "literal_error", "loc": ["body", "action"], "msg": "Input should be 'click', 'type', 'fill', 'press', 'scroll', 'set_clipboard' or 'wait_for'", "input": "hover", "ctx": {"expected": "'click', 'type', 'fill', 'press', 'scroll', 'set_clipboard' or 'wait_for'"}}]}
```

Notes: after an action the page is given up to 3 seconds to settle before the response, so the next `elements`, `screenshot` or `fetch` sees the result of the action. The first action after a navigate can take longer while the page is observed; later actions on the same page are sub-second. One action per call, 1 credit each.

## POST /v1/session/\{session\_id}/elements

List the actionable elements on the current tab, each with a selector that resolves back to that one element. This is the discovery step before `act`. Input values are never returned, only their length.

Request:

| Field | Type | Default | Meaning |
| - | - | - | - |
| `selector` | CSS selector, max 2048 | interactive elements | Restrict to elements matching it (`input`, `form a`, `#login button`). |
| `text` | string, max 512 | none | Keep elements whose accessible name contains it. |
| `visible_only` | boolean | `true` | Skip hidden elements. |
| `limit` | integer, 1 to 300 | 60 | Rows to return. `matched` tells you how many passed the filters. |

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/elements \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"limit": 8}'
```

Response on a form page (200; long selectors shortened):

```json theme={"dark"}
{
  "ok": true,
  "count": 8,
  "matched": 13,
  "elements": [
    {"selector": "html>body:nth-child(2)>form:nth-child(1)>p:nth-child(1)>label:nth-child(1)>input:nth-child(1)", "tag": "input", "input_name": "custname"},
    {"selector": "html>body:nth-child(2)>form:nth-child(1)>p:nth-child(2)>label:nth-child(1)>input:nth-child(1)", "tag": "input", "type": "tel", "input_name": "custtel"},
    {"selector": "html>body:nth-child(2)>form:nth-child(1)>p:nth-child(3)>label:nth-child(1)>input:nth-child(1)", "tag": "input", "type": "email", "input_name": "custemail"},
    {"selector": "...fieldset:nth-child(4)>p:nth-child(2)>label:nth-child(1)>input:nth-child(1)", "tag": "input", "type": "radio", "input_name": "size", "value_len": 5},
    {"selector": "...fieldset:nth-child(5)>p:nth-child(2)>label:nth-child(1)>input:nth-child(1)", "tag": "input", "type": "checkbox", "input_name": "topping", "value_len": 5}
  ],
  "truncated": true
}
```

On `https://example.com/` with an empty body:

```json theme={"dark"}
{"ok": true, "count": 1, "matched": 1, "elements": [{"selector": "html>body:nth-child(2)>a:nth-child(9)", "tag": "a", "name": "Learn more", "href": "https://iana.org/help/example-domains"}], "truncated": false}
```

Response fields: `ok`; `count` (rows returned); `matched` (rows that passed the filters); `elements[]` with `selector`, `tag`, and when present `name` (accessible name), `href`, `type`, `input_name`, `value_len`; `truncated` (true when `matched` exceeds `count`: narrow with `selector` or `text` rather than raising `limit`). A selector that matches nothing answers 200 with `count: 0`.

Errors: 400 `bad_selector` for a selector the browser cannot parse; 404 `element_not_found`; 409 `session has no open tab`; 409 `session_busy` when another call is driving; 409 `session_driven_by_client` when a main CDP, native Playwright, or VNC client holds the session; 502 `Data Plane element read failed` when the page could not be read. 1 credit per call on credit-funded accounts.

## POST /v1/session/\{session\_id}/screenshot

Capture the current tab as an image, base64 in JSON.

Request:

| Field | Type | Default | Meaning |
| - | - | - | - |
| `full_page` | boolean | `false` | Whole scrollable page instead of the viewport. |
| `format` | `jpeg` or `png` | `jpeg` | JPEG is far smaller. |
| `quality` | integer, 1 to 100 | 70 | JPEG quality. |
| `selector` | CSS selector, max 2048 | none | Clip the capture to one element. |

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/screenshot \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"format": "jpeg", "quality": 60}'
```

Response (200; the image is cut here):

```json theme={"dark"}
{
  "ok": true,
  "session_id": "sess_3d245e56b86d4bc8ae32095b644117d4",
  "format": "jpeg",
  "full_page": false,
  "image_base64": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAA...",
  "data_url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyU...",
  "size_b64": 53612
}
```

Response fields: `ok`, `session_id`, `format`, `full_page`, `image_base64` (the image), `data_url` (the same with a `data:` prefix, ready for an `<img>` or a vision model), `size_b64` (characters of base64; the PNG or JPEG is about three quarters of that in bytes).

Errors: 400 `bad_selector` (`detail: "screenshot element unavailable"`) when the element cannot be captured; fall back to the viewport; 404 `element_not_found` for a selector that matches nothing; 409 `session_busy` when another call is driving; 409 `session_driven_by_client` when a main CDP, native Playwright, or VNC client holds the session; 502 `Data Plane screenshot failed`. Billing: 1 action plus the base64 size in MB as egress on credit-funded accounts.

## GET /v1/session/\{session\_id}/tabs

Every open tab, oldest first. Free; counts under the drive throttle.

```json theme={"dark"}
{
  "session_id": "sess_3d245e56b86d4bc8ae32095b644117d4",
  "count": 1,
  "max": 15,
  "tabs": [
    {"tab_id": "AA1E0E90A01706F37CFCB865E1432EFB", "index": 0, "url": "https://example.com/", "title": "Example Domain"}
  ]
}
```

`max` reports the session's tab limit; do not hardcode it. `index` is the position in this list; `tab_id` is what `navigate` and `DELETE .../tabs/{tab_id}` take.

## POST /v1/session/\{session\_id}/tabs

Open a tab, optionally at a URL, and return it. 201 on success.

Request: `url` (optional, max 4096, http(s), same address rules as navigate).

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/tabs \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"url": "https://example.org/"}'
```

Response (201):

```json theme={"dark"}
{"tab_id": "21FEA677628AF1C2FFD0F465AEA076C5", "index": 1, "url": "https://example.org/", "title": "Example Domain"}
```

With an empty body: `{"tab_id": "84C437990B422ACB5A9893AB52832AB5", "index": 2, "url": "about:blank", "title": ""}`.

A navigate aimed at the new tab, `{"url": "https://example.com/", "tab_id": "21FEA677628AF1C2FFD0F465AEA076C5"}`, drives that tab and leaves the first where it was.

Errors: 400 for a blocked `url`; 429 `tab_limit` with the applicable `limit`; 502 `Data Plane tab creation failed` when the tab or its initial navigation could not be created. Read `/tabs` for the session's `max`. 1 credit per tab opened on credit-funded accounts.

## DELETE /v1/session/\{session\_id}/tabs/\{tab\_id}

Close one tab. 204 with no body.

Errors: 404 `No such tab` for an unknown or already closed id; 409 `last_tab` when it is the only tab, because a session with no tab cannot be driven; delete the session instead.

## GET /v1/session/\{session\_id}/context

Export the current tab's auth state (cookies, localStorage, sessionStorage) as JSON you store yourself. Hand it back with `POST .../context` on a later session to skip a login.

Response after visiting `https://httpbin.org/cookies/set/sessionid/abc123` (200):

```json theme={"dark"}
{
  "session_id": "sess_3d245e56b86d4bc8ae32095b644117d4",
  "context": {
    "url": "https://httpbin.org/cookies",
    "cookies": [
      {"name": "sessionid", "value": "abc123", "path": "/", "secure": false, "httpOnly": false, "url": "http://httpbin.org/", "sourcePort": 443, "sourceScheme": "Secure", "priority": "Medium"}
    ],
    "localStorage": {},
    "sessionStorage": {}
  },
  "cookies": 1
}
```

The blob contains real cookie values: store it encrypted. Errors: 502 `Data Plane context read failed`.

## POST /v1/session/\{session\_id}/context

Restore an exported context into a live session, then optionally navigate. Cookies are applied before the navigation, so the very first request is already authenticated.

Request:

| Field | Type | Meaning |
| - | - | - |
| `context` | object | The `context` object from GET, or a subset (`{"cookies": [...]}` is the common case). |
| `url` | string, max 4096 | Navigate here after restoring. When omitted, `context.url` names the origin the state belongs to; one of the two is required. |

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/context \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"context": {"cookies": [{"name": "sessionid", "value": "abc123", "path": "/", "url": "http://httpbin.org/"}]}, "url": "https://httpbin.org/cookies"}'
```

Response (200):

```json theme={"dark"}
{"session_id": "sess_3d245e56b86d4bc8ae32095b644117d4", "applied": {"cookies": 1, "localStorage": 0, "sessionStorage": 0}, "final_url": "https://httpbin.org/cookies"}
```

`applied` counts what really landed, so a zero tells you the origin refused the state rather than reporting success. Errors: 400 `context requires an HTTP(S) origin` when neither `url` nor `context.url` is an http(s) URL; 502 when the navigation failed, with the reason as the body. A restore with `url` is one action (1 credit); without it the call is free.

## POST /v1/session/\{session\_id}/files/fetch

Download a file through the session's browser and return its bytes. The request is made by the page itself, with the session's cookies and TLS identity, so files behind a login work.

Request: `url` (required, max 4096); `max_bytes` (optional, at least 1) to accept less than the 32 MiB cap.

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/files/fetch \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/"}'
```

Response (200; base64 cut):

```json theme={"dark"}
{
  "session_id": "sess_3d245e56b86d4bc8ae32095b644117d4",
  "filename": "download",
  "content_type": "text/html; charset=utf-8",
  "size": 577,
  "final_url": "https://example.com/",
  "content_base64": "PCFkb2N0eXBlIGh0bWw+PGh0bWwgbGFuZz1lbj48aGVhZD48bWV0YSBjaGFyc2V0PXV0Zi04PjxsaW5rIHJlbD1pY29uIGhy..."
}
```

`filename` comes from the final URL's last path segment (`download` when there is none), `size` is the byte count, `content_base64` the file.

Errors:

| Status | `error` | When |
| - | - | - |
| 413 | `too_large` (`size`, `limit`) | Over `max_bytes` or the 32 MiB cap: `{"detail": {"error": "too_large", "status": null, "network_error": null, "size": 101, "limit": 100}}` for `max_bytes: 100`. |
| 404 and other 4xx | `http_404` and so on (`status`) | The origin's own 4xx is passed through: `{"detail": {"error": "http_404", "status": 404, "network_error": null, "size": null, "limit": 33554432}}`. |
| 400 | `file_fetch_blocked`, or `unsafe url: ...` | The download was refused, or the target is a private address. |
| 409 | `unsupported_subresource_challenge` | A challenge stands in front of the file. |
| 504 | `file_fetch_timeout` (`retryable: true`) | The browser request exceeded its deadline; try a smaller file. |
| 502 | `file_fetch_failed` (`cause`, `message`, `retryable: true`) | Anything else. |

Billing: 1 action plus the file size in MB as egress.

## POST /v1/session/\{session\_id}/rotate-ip

Move the session to a fresh exit IP without changing the browser, cookies or tabs. Sessions keep one exit by default; call this when a site starts refusing, or between logical tasks. No body.

[Custom-proxy sessions](https://docs.tilion.dev/sessions/custom-proxy.md) return `409 customer_proxy_session`. Change the exit through your proxy provider instead.

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/rotate-ip \
  -H "Authorization: Bearer ph_live_..."
```

Response (200):

```json theme={"dark"}
{"session_id": "sess_3d245e56b86d4bc8ae32095b644117d4", "rotated": true, "region": "us-east-1", "fresh_allocation": false}
```

`region` is where the new exit sits. `fresh_allocation` is true when a brand-new address was allocated for the session, false when it moved to another address from the pool. On a residential session the session gets a new residential exit and `fresh_allocation` is true.

Errors: 409 with a reason slug when no rotation is possible right now (`ip_rotation_unavailable`, `exit_ladder_exhausted`, `residential_rotation_unavailable`); 503 `rotation_timeout` with `Retry-After`, leaving the session open; 402 and 429 like every drive call. A CDP client stays connected across the rotation. 1 credit on credit-funded accounts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.