# Irmu API reference

Source: https://irmu.com/api-reference
Base URL: `https://app.irmu.com/api`

Authenticate every request with `Authorization: Bearer $IRMU_API_KEY`.

## Crawl

### `GET /crawl`

Crawl a url

Charges the calling organization for the crawl and tallies it against
the url's host for the day, then returns what was requested and what it
cost.

The charge is decided before the fetch: see `credits_charged` below for
the price of each combination of options.


Fetch a single URL through a headless browser. The request is charged to the calling organization and tallied against the URL's host for the day. The response returns either the fetched document directly or a JSON envelope containing cost metadata and any AI-generated answer.

When to use it: Use this endpoint for one-off page retrieval when you need rendered content, proxy routing, or AI extraction. For bulk or asynchronous workloads, prefer the job-based flow. Use `premium` for hostile targets, disable `js` only when you know the page is server-rendered, and set `wait_until` to `domcontentloaded` or `load` for pages that keep persistent connections open.

Notes

- The response format branches on content type: `text/html` for a bare document, `application/json` when `ai_query` is present or a screenshot accompanies the page. Do not assume JSON from status alone.
- `js=true` is the default; disabling it on a client-rendered site returns an empty page and still charges credits. Verify the target serves meaningful HTML before turning it off.
- `country` requires an ISO 3166-1 alpha-2 code (`es`, not `spain`); `city` requires `country`, doubles the cost, and must match the pool's spelling exactly (`new_york`, not `new york`).

**Parameters**

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `url` | query | yes | string<uri> | The page to crawl. Must be `http` or `https` and must have a host. |
| `premium` | query | no | string | Fetch through the premium proxy pool instead of the standard one.
Costs more; use it for targets that refuse ordinary requests.

Every crawl is proxied either way -- this chooses which pool, not
whether there is one. `country` works on both.

Sent as text in a query string, so the spellings a person would
reach for all work: `true`, `false`, `1`, `0`, `yes`, `no`, `on`,
`off`, in any case. Anything that is not a yes or a no is refused
rather than quietly read as `false`.
 |
| `js` | query | no | string | Render javascript before reading the page. On by default, because it
is what works on most of the web -- a caller who knows their target
is static turns it off and pays less. Accepts the same spellings as
`premium`.

Off, the page comes back as the server delivered it: scripts are
neither run nor even downloaded, along with anything they would have
requested, which makes this the bluntest lever there is on how long a
crawl takes. Only use it where what you came for is in the served
html -- on a client-rendered site the page will be empty, and it is
still charged for.
 |
| `images` | query | no | string | Download the images on the page. **Off by default**, and the one
option here that costs more when you turn it on: 2 credits on top of
the fetch, whatever the fetch itself came to.

Off, image requests are aborted before they leave the browser and
everything else -- HTML, CSS, scripts, XHR -- still loads, so the page
behaves normally apart from having no pictures. An `<img>` tag is in
the html either way, so its `src` is still there to be read; what you
do not get is the file behind it. On a test page the difference
measured 422 KB against 166 KB.

Turn them on when the bytes are what you came for. Stacks with
`adblock`, which blocks a different set of requests.

Accepts the same spellings as `premium`.
 |
| `ai_query` | query | no | string | A question to answer about the page once it has been retrieved.
Costs 5 credits on top of the fetch. An empty value is the same as
asking nothing and costs nothing extra.
 |
| `adblock` | query | no | string | Drop ads and trackers before the page loads, using the EasyList
filters. **On by default**, and free either way -- it changes nothing
about the price.

Ad and tracker payloads dominate a typical retail page, and blocking
them cuts what has to be downloaded by around 80% while leaving the
page's own assets untouched, so this is the right default for almost
every target. What it also removes is anything an ad or analytics
script would have written into the document -- a tracking pixel's
payload, a `dataLayer` an ad tag populates. Send `adblock=false` if
that is what you came for.

Accepts the same spellings as `premium`.
 |
| `wait_until` | query | no | load | domcontentloaded | networkidle0 | networkidle2 | When the page counts as loaded. `networkidle0` by default -- it waits
for the network to go quiet, which is the slowest of the four and the
one most likely to return a finished page.

Use `domcontentloaded` or `load` for a page that keeps a connection
open (a socket, a poll, an analytics beacon), where waiting for
silence means waiting until the crawl times out.
 |
| `viewport` | query | no | string | The window size, written `WIDTHxHEIGHT`. Both sides must be positive.
Defaults to `1920x1080`.

Required to use `device_scale_factor`, `mobile`, `landscape` or
`touch`, which describe a viewport and are meaningless without one.
 |
| `device_scale_factor` | query | no | number | The device pixel ratio, as a retina screen reports it. `3` for a
recent phone. Requires `viewport`.
 |
| `mobile` | query | no | string | Tell the page it is on a phone. Requires `viewport`, and accepts the
same spellings as `premium`.

This changes the window metrics only. A site that decides what to
serve from the user agent will still send its desktop page, so pair
it with `user_agent`. And a page with no `<meta name="viewport">` tag
falls back to a 980px layout viewport whatever width you asked for --
standard browser behaviour rather than the setting being ignored.
 |
| `landscape` | query | no | string | Report the screen as landscape. Requires `viewport`, and accepts the
same spellings as `premium`.
 |
| `touch` | query | no | string | Report the screen as touch capable. Requires `viewport`, and accepts
the same spellings as `premium`.
 |
| `user_agent` | query | no | string | The user agent to send, replacing the one the crawler would otherwise
present.
 |
| `country` | query | no | string | The country to appear to be browsing from, as a two-letter ISO 3166-1
alpha-2 code (`es`, `us`, `de`). Case does not matter. Free, and it
works on either proxy pool, so it can be combined with `premium`.

Omit it to take whatever exit the pool picks.

A country name is not accepted -- `spain` is a `422`, `es` is not.
The pool reads the code out of the credentials it is given, so a name
would have to be mapped to a code, and that mapping is a list of wrong
answers waiting to be picked.

Required to use `city`, which narrows a country rather than standing
on its own.
 |
| `city` | query | no | string | The city inside that country to appear to be browsing from, as the
pool spells it. Case does not matter. **Requires `country`** -- a city
on its own is a `422`, because the pool reads it as the segment after
a country and not as a location of its own.

**A city doubles what the crawl costs**, surcharges included: exits in
one city are a far smaller pool than exits in a country. So
`country=de&city=berlin` on the cheapest fetch is 2 credits rather than
1, and on a premium rendered fetch carrying a question it is 60 rather
than 30. `credits_charged` is always the figure that was taken.

Letters, digits, hyphens and underscores only. A multi-word city is
written the way the pool writes it -- `new_york`, not `new york` -- and
anything carrying a `.` or a `;` is refused: those are the separators
the pool parses its own targeting with.

The `city` field of the response echoes the city actually targeted, so
it is what to check rather than what was sent.
 |
| `key` | query | no | string | The API key, for callers that cannot set a header. Accepted on this
route only -- a key in a url is copied into access logs, proxy logs
and `Referer` headers, which is a fair trade for a
machine-to-machine endpoint and a bad one for anything else.
Ignored when an `Authorization` header is present.
 |

**cURL**

```bash
curl -X GET "https://app.irmu.com/api/crawl?url=&premium=&js=&images=&ai_query=&adblock=&wait_until=&viewport=&device_scale_factor=&mobile=&landscape=&touch=&user_agent=&country=&city=&key=" \
  -H "Authorization: Bearer $IRMU_API_KEY"
```

**Node.js**

```javascript
const res = await fetch("https://app.irmu.com/api/crawl", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.IRMU_API_KEY}`,
    "Content-Type": "application/json",
  }
});

const data = await res.json();
```

**Python**

```python
import os, requests

res = requests.get(
    "https://app.irmu.com/api/crawl",
    headers={"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"}
)

print(res.json())
```

**Responses**

- `200` — The crawl finished, and the body is the page itself -- `text/html`,
with what it cost in headers, because a caller who asked for a url
wants the document rather than a document escaped inside a field.

A crawl that finished without producing a page -- which is what an
`ai_query` does -- answers the JSON envelope instead, with the answer
in `data`. Branch on the content type rather than on the status code.

One more rule for the same branch: a result that carries both a page
and a picture of one comes back as JSON, with the page in `content`,
rather than as a bare document that would drop the picture in
silence. Nothing produces that pair today -- it is stated so the rule
does not have to be guessed at if something does.


```json
"string"
```

- `202` — Paid for and running. The runner gives up on waiting long before it
gives up on crawling, so a slow page comes back as a job id rather
than a result -- collect it from `/crawl/{job}`, which is free.

The credits are already spent and the crawl already tallied: a `202`
is a crawl that is happening, not one that might.
A timeout also shows you where the crawl had got to: `debug` carries
the url the browser was on, a viewport screenshot and the page source
at that moment. Null when there is nothing to show yet -- a crawl
still in the queue has not opened a tab. It costs nothing.


```json
{
  "url": "https://example.com/products?page=2",
  "domain": "example.com",
  "premium": false,
  "js": true,
  "images": false,
  "city": null,
  "ai_query": "",
  "credits_charged": 5,
  "credits_remaining": 995,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "running",
  "content": null,
  "data": null,
  "position": 0,
  "error": null,
  "debug": {
    "url": "https://example.com/products?page=2",
    "screenshot": "/9j/4AAQSkZJRgABAQ...",
    "html": "<!doctype html>..."
  }
}
```

- `401` — No key, an unknown key, or a caller that is not an organization --
a signed-in dashboard user reaches this endpoint as a user and is
refused here, since a user has no credits to spend.


```json
{
  "message": "string"
}
```

- `402` — The organization's balance will not cover the crawl. Nothing is charged and nothing is recorded.

```json
{
  "message": "This organization does not have enough credits for that crawl."
}
```

- `422` — The request did not validate.

```json
{
  "message": "The url field is required.",
  "errors": {
    "url": [
      "The url field is required."
    ]
  }
}
```

- `500` — The crawler did not deliver. The charge is handed back --
`credits_charged` is `0` and the balance is whole -- and no crawl is
counted against the domain, though the attempt is recorded so a
customer whose target has started refusing us can see that we tried.

Reported as ours rather than as the caller's: the request was valid
and paid for, and the crawler is part of this service rather than a
third party they chose.


```json
{
  "url": "https://example.com/products?page=2",
  "domain": "example.com",
  "premium": false,
  "js": true,
  "images": false,
  "city": null,
  "ai_query": "",
  "credits_charged": 0,
  "credits_remaining": 1000,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "failed",
  "content": null,
  "data": null,
  "position": null,
  "error": "Navigation timeout of 30000 ms exceeded"
}
```

- `503` — The crawl could not be routed. Every crawl goes out through a proxy
pool, and the one this crawl's tier uses is not available -- a fault at
our end rather than anything wrong with the request.

Nothing is charged and nothing is recorded. The crawl is refused
rather than sent out directly, because connecting without the pool
would put our own address in front of the target under your name.
Retrying is reasonable; the same request will work once the pool is
back.


```json
{
  "message": "The crawler cannot route this crawl right now. Nothing was charged."
}
```


### `POST /crawl`

Run a recipe

Runs a **recipe** -- a list of blocks the crawler executes against a page
in order. This is the same endpoint as the `GET` above and the same work
billed to the same balance; what differs is who writes the recipe. A `GET`
names a url and some options and we build the blocks; here you send the
blocks, which is the only way to reach the rest of what the crawler does:
filling a form, paging through results, extracting fields.

**The body is the recipe. The options stay in the query string** -- the
same `premium`, `country`, `js` and the rest the `GET` takes, with the same
meanings. A field in the body that is not `blocks`, `name` or
`expectedOutput` is a `422` rather than an option.

**Your first block says where to start, and that is all it is used for.**
We read `blocks[0].details.source` and build the start block ourselves from
the query string, so `waitUntil`, `adblock`, `javascript`, `images`,
`viewport`, `userAgent` and the proxy are ours to set whatever your first
block says. Blocks after the first are run as you wrote them.

**30 credits, flat.** Neither the number of blocks nor `premium`,
`country` or `city` changes it -- what a recipe costs to run is a browser
session, which dwarfs the difference between fetching its first page one
way or another. Those options still decide where the crawl exits, and
against a target that refuses us they may decide whether it is let in at
all; they simply no longer move the bill.

The key goes in the `Authorization` header, or in `?key=` as on the `GET`.


Runs a custom recipe—a JSON array of blocks executed in order against a page. Unlike `GET /crawl`, you define the blocks, enabling form submission, pagination, field extraction, and other multi-step interactions. Query options (`premium`, `country`, `js`, etc.) control the browser session; the request body contains only the recipe.

When to use it: Use when `GET /crawl` is insufficient—any crawl requiring clicks, text input, multi-page navigation, or structured data extraction beyond a single page load.

- **First block is overridden**: `blocks[0].details.source` supplies the starting URL only; `waitUntil`, `adblock`, `javascript`, `images`, `viewport`, `userAgent`, and proxy settings are derived from query parameters, not your block definition.
- **Body fields are strict**: Any field outside `blocks`, `name`, or `expectedOutput` returns `422`; place all options in the query string.
- **Flat pricing**: 30 credits regardless of block count, `premium`, `country`, or `city`—cost is per browser session, not per-request complexity.

**Parameters**

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `premium` | query | no | boolean | Fetch through the premium proxy pool instead of the standard one. Use
it for targets that refuse ordinary requests. Unlike on the `GET`, it
does not change the price.
 |
| `js` | query | no | boolean | Render the page's javascript. On by default, and worth leaving on for
anything a recipe clicks or types into -- a block that waits for a
selector a script would have written will not find it. Does not change
the price here.
 |
| `images` | query | no | boolean | Download the images the page references. Off by default. Turn them on
for a recipe that screenshots, and leave them off otherwise: an `<img>`
tag is in the html whether or not the file behind it was fetched.
 |
| `adblock` | query | no | boolean | Block ads and trackers. On by default. See the `GET` for what it
removes and why it is worth leaving on.
 |
| `wait_until` | query | no | load | domcontentloaded | networkidle0 | networkidle2 | When the first page counts as loaded, passed to the browser as-is.
Defaults to `networkidle0`. A page that holds a connection open never
goes idle, so `domcontentloaded` is the one to reach for there.
 |
| `viewport` | query | no | string | The window size, as `WIDTHxHEIGHT`. Defaults to the crawler's
`1920x1080`. Required to use `device_scale_factor`, `mobile`,
`landscape` or `touch`.
 |
| `device_scale_factor` | query | no | number | The device pixel ratio. Requires `viewport`. |
| `mobile` | query | no | boolean | Report the browser as a mobile device. Requires `viewport`. |
| `landscape` | query | no | boolean | Report the viewport as landscape. Requires `viewport`. |
| `touch` | query | no | boolean | Report the device as touch-capable. Requires `viewport`. |
| `user_agent` | query | no | string | The `User-Agent` to send. Defaults to the crawler's own. |
| `country` | query | no | string | The country to appear to be browsing from, as an ISO 3166-1 alpha-2
code. Applies to both pools. Does not change the price here.
 |
| `city` | query | no | string | The city inside that country, as the pool spells it. **Requires
`country`.** Letters, digits, hyphens and underscores only.

Unlike on the `GET`, a city does not double what the crawl costs: the
price here is flat.
 |
| `key` | query | no | string | The API key, for callers that cannot set a header. Ignored when an
`Authorization` header is present.
 |

**Request body** (`application/json`)

```json
{
  "name": "Products, page 2",
  "expectedOutput": "list",
  "blocks": [
    {
      "type": "start",
      "details": {
        "type": "url",
        "source": "https://example.com/products"
      }
    },
    {
      "id": "rows",
      "type": "extract",
      "details": {
        "selector": ".product .title",
        "name": "title",
        "property": "textContent"
      }
    },
    {
      "type": "paginate",
      "details": {
        "selector": ".pagination .next",
        "startBlock": "rows"
      }
    }
  ]
}
```

**cURL**

```bash
curl -X POST "https://app.irmu.com/api/crawl?premium=&js=&images=&adblock=&wait_until=&viewport=&device_scale_factor=&mobile=&landscape=&touch=&user_agent=&country=&city=&key=" \
  -H "Authorization: Bearer $IRMU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Products, page 2", "expectedOutput": "list", "blocks": [ { "type": "start", "details": { "type": "url", "source": "https://example.com/products" } }, { "id": "rows", "type": "extract", "details": { "selector": ".product .title", "name": "title", "property": "textContent" } }, { "type": "paginate", "details": { "selector": ".pagination .next", "startBlock": "rows" } } ] }'
```

**Node.js**

```javascript
const res = await fetch("https://app.irmu.com/api/crawl", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.IRMU_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Products, page 2",
    "expectedOutput": "list",
    "blocks": [
      {
        "type": "start",
        "details": {
          "type": "url",
          "source": "https://example.com/products"
        }
      },
      {
        "id": "rows",
        "type": "extract",
        "details": {
          "selector": ".product .title",
          "name": "title",
          "property": "textContent"
        }
      },
      {
        "type": "paginate",
        "details": {
          "selector": ".pagination .next",
          "startBlock": "rows"
        }
      }
    ]
  })
});

const data = await res.json();
```

**Python**

```python
import os, requests

res = requests.post(
    "https://app.irmu.com/api/crawl",
    headers={"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"},
    json={
  "name": "Products, page 2",
  "expectedOutput": "list",
  "blocks": [
    {
      "type": "start",
      "details": {
        "type": "url",
        "source": "https://example.com/products"
      }
    },
    {
      "id": "rows",
      "type": "extract",
      "details": {
        "selector": ".product .title",
        "name": "title",
        "property": "textContent"
      }
    },
    {
      "type": "paginate",
      "details": {
        "selector": ".pagination .next",
        "startBlock": "rows"
      }
    }
  ]
}
)

print(res.json())
```

**Responses**

- `200` — The recipe finished. What comes back depends on what it was: a recipe
of one block -- just your start block -- is a request for the page, so
the body is the page itself as `text/html` with the cost in headers.
Anything longer extracted something, so the body is the JSON envelope
with the result in `data`.

Branch on the content type rather than on the status code.

One more rule for the same branch: a result that carries both a page
and a picture of one comes back as JSON, with the page in `content`,
rather than as a bare document that would drop the picture in
silence. Nothing produces that pair today -- it is stated so the rule
does not have to be guessed at if something does.


```json
"string"
```

- `202` — Paid for and running. A recipe that runs longer than the crawler waits
comes back as a job id -- collect it from `/crawl/{job}`, which is
free. The credits are already spent: a `202` is a crawl that is
happening, not one that might.
A timeout also shows you where the crawl had got to: `debug` carries
the url the browser was on, a viewport screenshot and the page source
at that moment. Null when there is nothing to show yet -- a crawl
still in the queue has not opened a tab. It costs nothing.


```json
{
  "url": "https://example.com/products",
  "domain": "example.com",
  "credits_charged": 30,
  "credits_remaining": 970,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "running",
  "content": null,
  "data": null,
  "position": 0,
  "error": null,
  "debug": {
    "url": "https://example.com/products",
    "screenshot": "/9j/4AAQSkZJRgABAQ...",
    "html": "<!doctype html>..."
  }
}
```

- `401` — No key, an unknown key, or a caller that is not an organization -- a
signed-in dashboard user is refused here, since a user has no credits
to spend.


```json
{
  "message": "string"
}
```

- `402` — The organization's balance will not cover the recipe. Nothing is charged and nothing is recorded.

```json
{
  "message": "This organization does not have enough credits for that request."
}
```

- `422` — The recipe did not validate. As well as the usual shape errors, this is
the answer for a block type the crawler does not run, a `start` block
anywhere but first, a `paginate` block naming an id no block carries,
and a field in the body that belongs in the query string.


```json
{
  "message": "Only the first block may be a start block. The crawl begins once, at the url it names.",
  "errors": {
    "blocks.2.type": [
      "Only the first block may be a start block. The crawl begins once, at the url it names."
    ]
  }
}
```

- `500` — The crawler did not deliver. The charge is handed back --
`credits_charged` is `0` and the balance is whole -- and no crawl is
counted against the domain, though the attempt is recorded.

Note what this does not cover: a recipe that ran to the end and
extracted nothing, because every selector missed, is a success. You
paid for the browser session and got it. Check `data` rather than the
status code for whether the recipe did what you meant.


```json
{
  "url": "https://example.com/products",
  "domain": "example.com",
  "credits_charged": 0,
  "credits_remaining": 1000,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "failed",
  "content": null,
  "data": null,
  "position": null,
  "error": "Navigation timeout of 30000 ms exceeded"
}
```

- `503` — The crawl could not be routed: the proxy pool this tier uses is not
available. Nothing is charged. Retrying is reasonable.


```json
{
  "message": "The crawler cannot route this crawl right now. Nothing was charged."
}
```


### `GET /crawl/{job}`

Collect a crawl

Collects a crawl that was still running when `/crawl` gave up waiting and
answered `202`.

Free, and it can be called as often as you like: the crawl was paid for
when it was queued, and asking whether your own crawl has finished is not
itself a crawl. Nothing is charged, no tally moves, and no daily limit
applies.

Ownership is proved by the id rather than looked up. A job id is the
organization's own id and a uuid joined by an underscore, and the runner
is never told who submitted what -- so an id that does not begin with the
calling organization's id is refused without anything being read. That
refusal is a `404`, the same answer an id that never existed gets: that
somebody else's crawl exists is not this caller's business.

The key goes in the `Authorization` header. Unlike `/crawl`, `?key=` is
not accepted here.


Retrieves the result of an asynchronous crawl job that previously returned `202 Accepted`. Use this endpoint after `/crawl` indicates a job is still running and you need to poll for completion.

When to use it: Call when `/crawl` responds with `202` and you need the final result, or when you want to monitor a long-running crawl's progress without consuming credits. Suitable for any polling strategy; no rate limits apply.

- **Authentication**: `Authorization` header only. Query parameter `?key=` is rejected.
- **Privacy by design**: Other organizations' job IDs return `404`, identical to nonexistent IDs. Job IDs are scoped to your organization prefix.
- **No credit headers**: `200` responses omit `credits_charged` and credit headers; collection is always free regardless of outcome.

**Parameters**

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `job` | path | yes | string | The `job_id` from the `202`, verbatim.
 |

**cURL**

```bash
curl -X GET "https://app.irmu.com/api/crawl/{job}" \
  -H "Authorization: Bearer $IRMU_API_KEY"
```

**Node.js**

```javascript
const res = await fetch("https://app.irmu.com/api/crawl/{job}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.IRMU_API_KEY}`,
    "Content-Type": "application/json",
  }
});

const data = await res.json();
```

**Python**

```python
import os, requests

res = requests.get(
    "https://app.irmu.com/api/crawl/{job}",
    headers={"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"}
)

print(res.json())
```

**Responses**

- `200` — The crawl has finished. As with `/crawl`, a page comes back as
`text/html` and anything else -- the answer to an `ai_query`, say --
as the JSON envelope. Branch on the content type.

No credit headers here, and no `credits_charged`: collecting costs
nothing, so there is nothing to report.


```json
"string"
```

- `202` — Still going. `position` says where it sits in the runner's queue
while it is `queued`, and is null once it is `running`.

A running crawl also carries `debug`: the url, a viewport screenshot
and the page source as they stood when this was asked. Polling again
takes a fresh look, so it is how to watch a slow crawl rather than
wait blind on it. Collecting is free, and so is looking.


```json
{
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "queued",
  "content": null,
  "data": null,
  "position": 2,
  "error": null
}
```

- `401` — No key, an unknown key, or a caller that is not an organization.


```json
{
  "message": "This endpoint is reached with an organization API key."
}
```

- `404` — No such crawl -- an id that never existed, or one belonging to
another organization. The two are deliberately indistinguishable.


```json
{
  "message": "No such crawl."
}
```

- `500` — The crawl failed after it had been accepted.

Nothing is refunded. The charge was taken when the crawl was queued,
and only a crawl that fails at `/crawl` itself -- before any work was
done -- is handed back. `error` says what went wrong.


```json
{
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "failed",
  "content": null,
  "data": null,
  "position": null,
  "error": "Navigation timeout of 30000 ms exceeded"
}
```


## ChatGPT

### `GET /chatgpt`

Ask ChatGPT

Puts a question to ChatGPT and returns the answer as structured JSON.

The crawler does the asking: it opens ChatGPT, types the prompt into the
composer, waits for the streamed answer and converts it into nodes --
paragraphs, headings, lists, tables, product lists, each keeping the
citations ChatGPT showed. There is no API key of yours involved anywhere,
and no model of ours: this drives the website.

**30 credits, flat.** Unlike `/crawl`, nothing changes that -- `premium`,
`country` and `city` still choose where the crawl exits, and for this
target that may well decide whether it is let in at all, but they do not
move the price. What this costs is a browser held open for up to two
minutes rather than a page's worth of bytes.

Always JSON, never `text/html`. There is nothing to branch on here.

The key goes in the `Authorization` header, or in `?key=` as on `/crawl`.


Sends a prompt to ChatGPT through a browser automation and returns the response as structured JSON nodes—paragraphs, headings, lists, tables, product lists—each preserving citations. The crawler types the prompt into ChatGPT's web interface, waits for the streamed reply, and parses it. No OpenAI API key is required; this drives the public website directly.

**When to use it.** Use when you need ChatGPT's output with citation metadata intact, or when you lack API access to the underlying model. For simple text extraction from static pages, `/crawl` is cheaper and faster. This endpoint is priced flat at 30 credits regardless of proxy options.

**Notes**

- Prompts are typed character-by-character: the 1000-character limit is a hard ceiling, and long prompts consume significant wait time before generation begins.
- A 202 response means the job is still running; collect it from `/crawl/{job}`, but expect the answer nested under `data[0].conversation` rather than top-level `conversation`.
- Failed or unanswered prompts refund credits (`credits_charged: 0`), though the attempt is still logged against `chatgpt.com`.

**Parameters**

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `prompt` | query | yes | string | The question, up to 1000 characters.

The limit is not arbitrary. The prompt is typed one character at a
time, so a thousand characters is already half a minute of typing
before ChatGPT starts to think -- against a crawler that waits about
two and a half minutes for the whole job.
 |
| `premium` | query | no | string | Ask through the premium proxy pool instead of the standard one. Free
here, unlike on `/crawl` -- the price is flat -- but worth trying if
the standard pool is being turned away.

Sent as text in a query string, so the spellings a person would reach
for all work: `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off`, in
any case. Anything that is not a yes or a no is refused rather than
quietly read as `false`.
 |
| `country` | query | no | string | The country to appear to be asking from, as a two-letter ISO 3166-1
alpha-2 code. Free, and it changes what ChatGPT answers as much as it
changes anything -- a question about prices asked from Germany is not
the same question.

Required to use `city`.
 |
| `city` | query | no | string | The city inside that country, as the pool spells it. **Requires
`country`.** Free here, where on `/crawl` it doubles the price.

Letters, digits, hyphens and underscores only; a multi-word city is
written `new_york`.
 |
| `key` | query | no | string | The API key, as an alternative to the `Authorization` header. Ignored
when a header is present. A key in a url is copied into access logs,
proxy logs and `Referer` headers, which is a fair trade for a
machine-to-machine endpoint and a bad one for anything else.
 |

**cURL**

```bash
curl -X GET "https://app.irmu.com/api/chatgpt?prompt=&premium=&country=&city=&key=" \
  -H "Authorization: Bearer $IRMU_API_KEY"
```

**Node.js**

```javascript
const res = await fetch("https://app.irmu.com/api/chatgpt", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.IRMU_API_KEY}`,
    "Content-Type": "application/json",
  }
});

const data = await res.json();
```

**Python**

```python
import os, requests

res = requests.get(
    "https://app.irmu.com/api/chatgpt",
    headers={"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"}
)

print(res.json())
```

**Responses**

- `200` — ChatGPT answered. `conversation` holds the exchange: your prompt, then
the answer as a list of nodes.


```json
{
  "prompt": "elon musk",
  "conversation": [
    {
      "role": "user",
      "message": "elon musk"
    },
    {
      "role": "assistant",
      "response": [
        {
          "type": "paragraph",
          "text": "Elon Musk is a businessman known for Tesla and SpaceX.",
          "sources": [
            {
              "attribution": "Reuters",
              "url": "https://example.com/article"
            }
          ]
        },
        {
          "type": "heading",
          "level": 2,
          "text": "Companies"
        },
        {
          "type": "bullet_list",
          "items": [
            {
              "text": "Tesla"
            },
            {
              "text": "SpaceX"
            }
          ]
        }
      ]
    }
  ],
  "credits_charged": 30,
  "credits_remaining": 970,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "done",
  "position": null,
  "error": null
}
```

- `202` — Paid for and still going. A long prompt can outlast the crawler's
patience for waiting, though not its patience for working.

Collect it from `/crawl/{job}` -- the same endpoint a slow crawl is
collected from, and free in the same way. One difference worth knowing:
that endpoint answers the crawl envelope, so the answer arrives under
`data[0].conversation` rather than lifted into `conversation` the way
it is here.
A prompt that is still running also carries `debug`: the url, a
viewport screenshot and the page source as they stood when the
crawler stopped waiting, so a slow prompt shows the chat as it was
rather than nothing at all. Null once it finishes -- the tab is gone
by then. It costs nothing.


```json
{
  "prompt": "elon musk",
  "conversation": [],
  "credits_charged": 30,
  "credits_remaining": 970,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "running",
  "position": null,
  "error": null,
  "debug": {
    "url": "https://chatgpt.com/",
    "screenshot": "/9j/4AAQSkZJRgABAQ...",
    "html": "<!doctype html>..."
  }
}
```

- `401` — No key, an unknown key, or a caller that is not an organization.


```json
{
  "message": "This endpoint is reached with an organization API key."
}
```

- `402` — Not enough credits, or the daily limit is reached. Nothing is charged
and no attempt is recorded.


```json
{
  "message": "string"
}
```

- `422` — A malformed request -- no prompt, a prompt over the limit, a city with
no country, or a flag that is not a yes or a no.


```json
{
  "message": "string",
  "errors": {}
}
```

- `500` — Nothing came back. Either the crawl failed outright, or ChatGPT did not
answer within its timeout -- which reads as an empty `conversation` and
an `error` saying so.

**The credits are refunded either way**, so `credits_charged` is `0`.
The attempt is still recorded against `chatgpt.com`, so a customer can
see that we tried.


```json
{
  "prompt": "elon musk",
  "conversation": [],
  "credits_charged": 0,
  "credits_remaining": 1000,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "done",
  "position": null,
  "error": "ChatGPT did not answer in time. Nothing was charged."
}
```


## Screenshot

### `GET /screenshot`

Screenshot a url

Takes a full-page screenshot of a url and returns it as base64 JPEG.

The same request as `/crawl` -- the same url, the same fetch options, the
same `ai_query` -- answered with a picture of the page instead of its
html. The crawler loads the page exactly as it would for a crawl, then
photographs the whole of it: not the viewport, the entire scroll height.

**30 credits, flat.** Unlike `/crawl`, none of the options change that.
`premium`, `js`, `country` and `city` still choose how the page is fetched
and where the crawl exits, `images` and `ai_query` still cost nothing
extra, and a city does not double the bill. What this costs is a browser
laying out, scrolling and encoding a whole page rather than a page's worth
of bytes.

Two things to know before the first call. **`images` is on by default
here**, against off on `/crawl`, because a page photographed without its
pictures has empty boxes where they should be. And the response can be
large: a long page runs to several megabytes and base64 adds about a third
on top, so a tall target is worth a `viewport` and a thought about
timeouts.

Always JSON, never `text/html`. There is nothing to branch on here.

The key goes in the `Authorization` header, or in `?key=` as on `/crawl`.


Captures a full-page screenshot of a URL and returns it as a base64-encoded JPEG. Uses the same request parameters as `/crawl` — same URL, same fetch options, same `ai_query` — but returns a photographic rendering of the entire scroll height rather than HTML.

**When to use it:** Use when you need to visually preserve or inspect a page exactly as a browser renders it, including full scroll height, rather than extracting its markup. Useful for archival, visual regression, compliance capture, or any workflow where the rendered appearance matters more than the underlying structure. The flat 30-credit pricing makes costs predictable regardless of proxy tier, geolocation, or AI query usage.

**Notes**
- `images` defaults to `true` (opposite of `/crawl`), since pages photographed without images show empty boxes where assets belong.
- Response payloads grow large: long pages run to several megabytes, and base64 encoding adds ~33% overhead; consider constraining `viewport` and reviewing timeouts for tall targets.
- Asynchronous jobs return `202` — collect completed screenshots from `/crawl/{job}` under `data.screenshots[0]`, not the top-level `screenshot` field used by synchronous `200` responses.

**Parameters**

| Name | In | Required | Type | Description |
| --- | --- | --- | --- | --- |
| `url` | query | yes | string<uri> | The page to photograph. Must be `http` or `https` and must have a host. |
| `premium` | query | no | string | Fetch through the premium proxy pool instead of the standard one.
Free here, unlike on `/crawl` -- the price is flat -- and worth trying
for targets that refuse ordinary requests.

Every crawl is proxied either way -- this chooses which pool, not
whether there is one. `country` works on both.

Sent as text in a query string, so the spellings a person would
reach for all work: `true`, `false`, `1`, `0`, `yes`, `no`, `on`,
`off`, in any case. Anything that is not a yes or a no is refused
rather than quietly read as `false`.
 |
| `js` | query | no | string | Render javascript before taking the picture. On by default, because
it is what works on most of the web. Free here, unlike on `/crawl`.
Accepts the same spellings as `premium`.

Off, the page is photographed as the server delivered it: scripts are
neither run nor even downloaded, along with anything they would have
requested. That is faster, and on most of the modern web it is a
picture of an unfinished page -- a client-rendered site photographs
blank, and it is still charged for. Turn it off only for a target you
know is served finished.
 |
| `images` | query | no | string | Download the images on the page. **On by default here**, which is
the opposite of `/crawl` and the only parameter the two endpoints
disagree about: what comes back is a picture of the page, and a page
photographed without its pictures has empty boxes where they should
be. Free either way -- the 2-credit surcharge `/crawl` charges for
these does not apply to a flat price.

Turning them off is supported for the caller who wants exactly that:
image requests are then aborted before they leave the browser while
everything else -- HTML, CSS, scripts, XHR -- still loads, so the page
lays out normally and photographs faster, with holes in it. On a test
page the difference measured 422 KB against 166 KB.

Stacks with `adblock`, which blocks a different set of requests.

Accepts the same spellings as `premium`.
 |
| `ai_query` | query | no | string | A question to answer about the page, put to a model once the page
has loaded and before the picture is taken. Free here, where `/crawl`
charges 5 credits for it -- the price is flat. An empty value is the
same as asking nothing.

The answer comes back under `data`, beside the image rather than
instead of it.
 |
| `adblock` | query | no | string | Drop ads and trackers before the page loads, using the EasyList
filters. **On by default**, and free either way -- it changes nothing
about the price.

Ad and tracker payloads dominate a typical retail page, and blocking
them cuts what has to be downloaded by around 80% while leaving the
page's own assets untouched, so this is the right default for almost
every target. What it also removes is anything an ad or analytics
script would have written into the document -- a tracking pixel's
payload, a `dataLayer` an ad tag populates. Send `adblock=false` if
that is what you came for.

Accepts the same spellings as `premium`.
 |
| `wait_until` | query | no | load | domcontentloaded | networkidle0 | networkidle2 | When the page counts as loaded. `networkidle0` by default -- it waits
for the network to go quiet, which is the slowest of the four and the
one most likely to return a finished page.

Use `domcontentloaded` or `load` for a page that keeps a connection
open (a socket, a poll, an analytics beacon), where waiting for
silence means waiting until the crawl times out.
 |
| `viewport` | query | no | string | The window size, written `WIDTHxHEIGHT`. Both sides must be positive.
Defaults to `1920x1080`.

Required to use `device_scale_factor`, `mobile`, `landscape` or
`touch`, which describe a viewport and are meaningless without one.
 |
| `device_scale_factor` | query | no | number | The device pixel ratio, as a retina screen reports it. `3` for a
recent phone. Requires `viewport`.
 |
| `mobile` | query | no | string | Tell the page it is on a phone. Requires `viewport`, and accepts the
same spellings as `premium`.

This changes the window metrics only. A site that decides what to
serve from the user agent will still send its desktop page, so pair
it with `user_agent`. And a page with no `<meta name="viewport">` tag
falls back to a 980px layout viewport whatever width you asked for --
standard browser behaviour rather than the setting being ignored.
 |
| `landscape` | query | no | string | Report the screen as landscape. Requires `viewport`, and accepts the
same spellings as `premium`.
 |
| `touch` | query | no | string | Report the screen as touch capable. Requires `viewport`, and accepts
the same spellings as `premium`.
 |
| `user_agent` | query | no | string | The user agent to send, replacing the one the crawler would otherwise
present.
 |
| `country` | query | no | string | The country to appear to be browsing from, as a two-letter ISO 3166-1
alpha-2 code (`es`, `us`, `de`). Case does not matter. Free, and it
works on either proxy pool, so it can be combined with `premium`.

Omit it to take whatever exit the pool picks.

A country name is not accepted -- `spain` is a `422`, `es` is not.
The pool reads the code out of the credentials it is given, so a name
would have to be mapped to a code, and that mapping is a list of wrong
answers waiting to be picked.

Required to use `city`, which narrows a country rather than standing
on its own.
 |
| `city` | query | no | string | The city inside that country to appear to be browsing from, as the
pool spells it. Case does not matter. **Requires `country`** -- a city
on its own is a `422`, because the pool reads it as the segment after
a country and not as a location of its own.

Free here, where on `/crawl` it doubles the price: this endpoint is
flat. Worth knowing that it is the slowest option to serve -- exits in
one city are a far smaller pool than exits in a country.

Letters, digits, hyphens and underscores only. A multi-word city is
written the way the pool writes it -- `new_york`, not `new york` -- and
anything carrying a `.` or a `;` is refused: those are the separators
the pool parses its own targeting with.

Unlike `/crawl`, this endpoint does not echo the city back: there is
no price for it to explain.
 |
| `key` | query | no | string | The API key, for callers that cannot set a header. Accepted on the
metered routes only -- a key in a url is copied into access logs,
proxy logs and `Referer` headers, which is a fair trade for a
machine-to-machine endpoint and a bad one for anything else.
Ignored when an `Authorization` header is present.
 |

**cURL**

```bash
curl -X GET "https://app.irmu.com/api/screenshot?url=&premium=&js=&images=&ai_query=&adblock=&wait_until=&viewport=&device_scale_factor=&mobile=&landscape=&touch=&user_agent=&country=&city=&key=" \
  -H "Authorization: Bearer $IRMU_API_KEY"
```

**Node.js**

```javascript
const res = await fetch("https://app.irmu.com/api/screenshot", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.IRMU_API_KEY}`,
    "Content-Type": "application/json",
  }
});

const data = await res.json();
```

**Python**

```python
import os, requests

res = requests.get(
    "https://app.irmu.com/api/screenshot",
    headers={"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"}
)

print(res.json())
```

**Responses**

- `200` — The picture was taken. `screenshot` holds it: base64, JPEG, full page.

Decode it as it stands -- there is no `data:` prefix and no padding to
strip:

```
curl -s -H "Authorization: Bearer $KEY" \
  "https://irmu.ddev.site/api/screenshot?url=https://example.com" \
  | jq -r .screenshot | base64 -d > shot.jpg
```


```json
{
  "url": "https://example.com/",
  "domain": "example.com",
  "screenshot": "/9j/4AAQSkZJRgABAQEAYABgAAD...",
  "ai_query": "",
  "credits_charged": 30,
  "credits_remaining": 970,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "done",
  "data": {
    "url": "https://example.com/"
  },
  "position": null,
  "error": null
}
```

- `202` — Paid for and still going. A tall page, or one that is slow to settle,
can outlast the crawler's patience for waiting -- though not its
patience for working.

Collect it from `/crawl/{job}`, the same endpoint a slow crawl is
collected from, and free in the same way. One difference worth
knowing: that endpoint answers the crawl envelope, so the image
arrives under `data.screenshots[0]` rather than lifted into
`screenshot` the way it is here.

A running crawl also carries `debug`, and its picture is not the one
you paid for: it is a viewport thumbnail off the live tab, showing
where the crawl had got to. `screenshot` stays null until the
full-page image exists. Null once it finishes, and free either way.


```json
{
  "url": "https://example.com/",
  "domain": "example.com",
  "screenshot": null,
  "ai_query": "",
  "credits_charged": 30,
  "credits_remaining": 970,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "running",
  "data": null,
  "position": 2,
  "error": null,
  "debug": {
    "url": "https://example.com/",
    "screenshot": "/9j/4AAQSkZJRgABAQ...",
    "html": "<!doctype html>..."
  }
}
```

- `401` — No key, an unknown key, or a caller that is not an organization.


```json
{
  "message": "This endpoint is reached with an organization API key."
}
```

- `402` — Not enough credits, or the daily limit is reached. Nothing is charged
and no attempt is recorded.


```json
{
  "message": "string"
}
```

- `422` — A malformed request -- no url, a url that is not `http`/`https`, a city
with no country, a `viewport` that is not `WIDTHxHEIGHT`, or a flag
that is not a yes or a no.


```json
{
  "message": "string",
  "errors": {}
}
```

- `500` — No picture came back. Either the crawl failed outright, or it finished
without producing an image -- which reads as a null `screenshot` and an
`error` saying so.

**The credits are refunded either way**, so `credits_charged` is `0`.
The attempt is still recorded against the domain, so a customer can
see that we tried.


```json
{
  "url": "https://example.com/",
  "domain": "example.com",
  "screenshot": null,
  "ai_query": "",
  "credits_charged": 0,
  "credits_remaining": 1000,
  "job_id": "0198a9b6-1f4c-7c3e-9a2b-5d8e6f0a1b23_0198a9b6-2a71-7f0d-8c44-9e1b3c5d7f02",
  "status": "done",
  "data": {
    "url": "https://example.com/"
  },
  "position": null,
  "error": "The crawler took no screenshot of that page. Nothing was charged."
}
```

- `503` — The crawl could not be routed through a proxy pool. Nothing is
charged. The request was fine and will be fine again once the pool is
configured.


```json
{
  "message": "The crawler cannot route this crawl right now. Nothing was charged."
}
```


## Errors

| Status | Meaning |
| --- | --- |
| 401 | No key, an unknown key, or a caller that is not an organization --
a signed-in dashboard user reaches this endpoint as a user and is
refused here, since a user has no credits to spend.
 |
| 402 | The organization's balance will not cover the crawl. Nothing is charged and nothing is recorded. |
| 404 | No such crawl -- an id that never existed, or one belonging to
another organization. The two are deliberately indistinguishable.
 |
| 422 | The request did not validate. |
| 500 | The crawler did not deliver. The charge is handed back --
`credits_charged` is `0` and the balance is whole -- and no crawl is
counted against the domain, though the attempt is recorded so a
customer whose target has started refusing us can see that we tried.

Reported as ours rather than as the caller's: the request was valid
and paid for, and the crawler is part of this service rather than a
third party they chose.
 |
| 503 | The crawl could not be routed. Every crawl goes out through a proxy
pool, and the one this crawl's tier uses is not available -- a fault at
our end rather than anything wrong with the request.

Nothing is charged and nothing is recorded. The crawl is refused
rather than sent out directly, because connecting without the pool
would put our own address in front of the target under your name.
Retrying is reasonable; the same request will work once the pool is
back.
 |
