Irmu
API Reference

Run a recipe

POST /crawl — bearer auth, JSON responses, billed per successful request.

POST/crawl

Run a recipe

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.
ParameterInTypeDescription
premiumquerybooleanFetch 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.
jsquerybooleanRender 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.
imagesquerybooleanDownload 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.
adblockquerybooleanBlock ads and trackers. On by default. See the `GET` for what it removes and why it is worth leaving on.
wait_untilqueryload | domcontentloaded | networkidle0 | networkidle2When 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.
viewportquerystringThe window size, as `WIDTHxHEIGHT`. Defaults to the crawler's `1920x1080`. Required to use `device_scale_factor`, `mobile`, `landscape` or `touch`.
device_scale_factorquerynumberThe device pixel ratio. Requires `viewport`.
mobilequerybooleanReport the browser as a mobile device. Requires `viewport`.
landscapequerybooleanReport the viewport as landscape. Requires `viewport`.
touchquerybooleanReport the device as touch-capable. Requires `viewport`.
user_agentquerystringThe `User-Agent` to send. Defaults to the crawler's own.
countryquerystringThe 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.
cityquerystringThe 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.
keyquerystringThe API key, for callers that cannot set a header. Ignored when an `Authorization` header is present.
request.sh
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" } } ] }'
200.json
"string"
request.js
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();
request.py
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())
StatusMeaning
200The 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.
202Paid 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.
401No 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.
402The organization's balance will not cover the recipe. Nothing is charged and nothing is recorded.
422The 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.
500The 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.
503The crawl could not be routed: the proxy pool this tier uses is not available. Nothing is charged. Retrying is reasonable.

Start building with Irmu today

200 free credits every month, no card required. Every API, every integration, one key.