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.sourcesupplies 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, orexpectedOutputreturns422; place all options in the query string. - Flat pricing: 30 credits regardless of block count,
premium,country, orcity—cost is per browser session, not per-request complexity.
| Parameter | In | Type | Description |
|---|---|---|---|
| premium | query | 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 | 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 | 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 | 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 | 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 | 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 | number | The device pixel ratio. Requires `viewport`. |
| mobile | query | boolean | Report the browser as a mobile device. Requires `viewport`. |
| landscape | query | boolean | Report the viewport as landscape. Requires `viewport`. |
| touch | query | boolean | Report the device as touch-capable. Requires `viewport`. |
| user_agent | query | string | The `User-Agent` to send. Defaults to the crawler's own. |
| country | query | 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 | 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 | string | The 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())| Status | Meaning |
|---|---|
| 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. |
| 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. |
| 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. |
| 402 | The organization's balance will not cover the recipe. Nothing is charged and nothing is recorded. |
| 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. |
| 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. |
| 503 | The 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.