# HTML Table to JSON & CSV - Web Table Extractor (`neverempty/html-table-to-json`) Actor

For AI agents and data pipelines: give a URL, get every HTML table on the page as JSON rows keyed by column name, with merged cells expanded, multi-row headers joined, numbers typed and footnotes removed. On 48 real pages, 454 tables matched what a browser draws, cell by cell. CSV too. Monitor mode.

- **URL**: https://apify.com/neverempty/html-table-to-json.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Developer tools, AI, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.80 / 1,000 page with tables returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## HTML Table to JSON & CSV - Web Table Extractor

**For AI agents and data pipelines:** give it a web page URL, get back **every HTML table on that page as JSON**: one object per row, keyed by the column names from the table's own header rows, with merged cells filled in, numbers as numbers, and footnote marks removed.

Page-reading tools often break tables: rows and columns slide out of line when a cell spans two rows, a two-level header turns into two unrelated header rows, and some readers drop the table altogether. This Actor rebuilds each table cell by cell the way a browser lays it out, then gives you rows you can use directly.

Input and output in one look:

```json
{ "urls": ["https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue"], "tableIndexes": ["1"] }
```

```json
{
  "status": "ok",
  "url": "https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue",
  "tablesFound": 6,
  "tablesReturned": 1,
  "tables": [
    {
      "index": 1,
      "title": "List",
      "headerSource": "th-rows",
      "headerRowCount": 2,
      "rowCount": 50,
      "columnCount": 9,
      "columns": [
        { "name": "Ranks", "type": "number" },
        { "name": "Name", "type": "text" },
        { "name": "Revenue › USD (in billions)", "type": "number", "headerParts": ["Revenue", "USD (in billions)"] },
        { "name": "Employees", "type": "number" }
      ],
      "rows": [
        { "Ranks": 1, "Name": "Amazon", "Industry": "Retail Information technology", "Revenue › USD (in billions)": 716, "Profit › USD (in billions)": 79.9, "Employees": 1576000, "Headquarters": "United States", "State-owned": null, "Ref.": null },
        { "Ranks": 2, "Name": "Walmart", "Industry": "Retail", "Revenue › USD (in billions)": 713, "Profit › USD (in billions)": 21.8, "Employees": 2100000, "Headquarters": "United States", "State-owned": null, "Ref.": null }
      ],
      "rowsText": [
        { "Ranks": "1", "Name": "Amazon", "Revenue › USD (in billions)": "716", "Employees": "1,576,000", "Headquarters": "United States" }
      ],
      "spanCopies": [{ "row": 1, "column": "Headquarters", "copiedFrom": "above (rowspan)" }],
      "footnotesRemoved": 52
    }
  ],
  "otherTables": [
    { "index": 2, "title": "Breakdown by country", "reason": "not selected (tableIndexes 1)", "gridRows": 13, "gridColumns": 3 },
    { "index": 3, "title": "External links", "reason": "layout table: navigation box (links to other pages)", "gridRows": 5, "gridColumns": 2 }
  ]
}
```

(Real values from a run on 2026-09-25, shortened: `columns` and `rowsText` show 4 and 1 of their entries. Leave `urls` empty and the Actor reads the example page `https://en.wikipedia.org/wiki/List_of_countries_and_dependencies_by_population`.)

### What it handles

| Problem in the HTML | What you get |
|---|---|
| **Merged cells** (`rowspan`, `colspan`) | The value is copied into every cell it covers, so every row has every column. Each copy is marked in `spanCopies` (`above (rowspan)` or `left (colspan)`; up to 500 per table, `spanCopyCount` has the total), so you can tell a copied value from a written one. A `rowspan` stops at the end of its `<thead>` / `<tbody>` / `<tfoot>` and `rowspan="0"` runs to the end of it, as in a browser. |
| **Two or three header rows** | Column names join the levels with `›`: `Revenue › USD (in billions)`, `気温(℃) › 平均 › 日最高`. The levels are also in `columns[].headerParts`. A header cell that spans two header rows is used once (`Employees`, not `Employees › Employees`). |
| **Numbers written as text** | Columns where at least 80% of the cells read as numbers get `type: "number"` and the numbers in `rows`: thousands separators (`1,234,567` / `1.234.567,8` / `1 234 567` / `1'234`), currency signs (`$`, `€`, `£`, `¥`, `円`, `₹` ...), `%`, units (`km`, `kg`, `°C`, `人` ...), `−` minus signs, `about` / `約` / `~`, and the words `million` / `billion` / `万` / `億` (multiplied out). The decimal separator is decided per column from the cells themselves, and only when that is impossible from the page language. `columns[].unit`, `currency` and `decimalSeparator` say what was removed. |
| **Cells that are not plain numbers** | Never guessed: in a number column a cell like `1,234 (2023)` is `null` in `rows` and kept as written in `rowsText` (counted in `unparsedNumberCells`). The same goes for values with `<`, `>`, `±`, `over`, `under` or `up to` (a limit is not the value), for a one-letter suffix such as `$1.2M`, `850K` or `5m` (million, thousand, metres or minutes cannot be told apart), and for numbers with more than 15 digits (they would be rounded). Values marked `about`, `~` or `約` are numbers and are counted in `columns[].approximateCells`. A column with a code that starts with 0 (`008`, postal codes) stays text, so no zero is lost. Dashes, `n/a` and empty cells are `null`, not 0. |
| **Footnote marks** | `[1]`, `[a]`, `[note 3]`, `[citation needed]` and `<sup>` reference marks are removed from cells and captions (`footnotesRemoved`). Turn `stripFootnotes` off to keep them. |
| **Header cells at the start of each row** (`<th scope="row">`) | They stay as the first column of each row. |
| **Group rows inside the body** (one cell across the whole table, such as a region name) | Not returned as a row; their text goes into a `Section` column on the rows under them (marked `derived` in `columns`). |
| **Header rows repeated inside a long table, empty rows** | Left out and counted (`repeatedHeaderRowsDropped`, `emptyRowsDropped`). |
| **Tables inside tables** | Each table is returned on its own; the outer table's cell does not swallow the inner table's text (`nestedInTable` names the outer table). |
| **Text hidden on the page** | `display:none` / `hidden` elements, Wikipedia's hidden sort keys and empty rows, and text that sites show only on phones (`md:hidden`, `show-mobile`, `d-none`) or only on hover (tooltips) are not read. |
| **Layout tables** | Tables used for page layout (one row, one column without header cells, only images, cells holding other tables or long paragraphs, `role="presentation"`, navigation boxes) are not returned; they are listed in `otherTables` with the reason. `includeLayoutTables` returns them too. |

**Checked against a browser:** on 48 real pages captured on 2026-09-25 (Wikipedia in English, German, French and Japanese, BLS, IRS, the Federal Reserve, EIA, the Bank of England, the ECB, GOV.UK, the Japan Meteorological Agency, the Bank of Japan, Japan's National Tax Agency, iban.com, x-rates.com, worldometers, TIOBE, ESPN, CBS Sports, Sky Sports, Formula 1, Hacker News, MDN and W3Schools) and one hand-written page of broken and unusual table markup, the rows, columns and cell text of **454 tables (58,621 cells)** match what Chromium draws from the same HTML, cell by cell. 12 of the real pages were captured only after the reading rules were finished, as a blind check: 80 of their 82 tables matched on the first try, and the 2 that did not (a table with stray `</tr>` tags, and a hidden coordinate format on Wikipedia) were fixed. 4 tables with zero-width spacer cells cannot be compared by position and are not in the count.

### Choosing tables

By default every data table on the page is returned. To get fewer:

- `tableIndexes`: the nth `<table>` on the page, counted from 1 (the same `index` you see in `tables` and `otherTables`).
- `titleContains`: text in the table's `<caption>`, `aria-label` or the nearest heading above it.
- `columnContains`: text in any column name.
- `minRows`: skip small tables.

Every table that is not returned is listed in `otherTables` with its index, title, size and the reason, so an agent can pick the right one in a second call.

### What it cannot read (and says so)

- **Tables drawn by JavaScript.** The Actor reads the HTML the server sends and does not run JavaScript. If that HTML has no `<table>`, the row says `no-tables` and that a table you see in a browser is drawn by JavaScript or built from `<div>` tags. It never says "this page has no table" for such a page. (Example: `https://aws.amazon.com/ec2/pricing/on-demand/` sends no `<table>`.) A table whose HTML has header rows but no data rows (or only a "Loading..." row) gets its rows from JavaScript too: it is listed in `otherTables` as `no data rows in the HTML`, never returned with its header as a data row, and the page is marked `likelyNeedsJavaScript`.
- **Pages behind a sign-in or a check page** (Cloudflare, DataDome, PerimeterX, AWS WAF, Akamai ...): the row says `login-required` or `blocked`. The Actor never signs in, never solves or bypasses check pages and does not switch to proxies to get around a refusal.
- **Addresses robots.txt does not allow.** The Actor checks each site's robots.txt (RFC 9309, as `NeverEmptyTables`; if a site has no group for that name, `User-agent: *` applies) for every URL and every redirect, and does not request disallowed addresses (`robots-disallowed`).
- Classes that a site hides with its own CSS file (other than the common ones above) cannot be seen without that CSS; such text is read.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `urls` | array of strings | (empty: the Wikipedia population list is used) | Pages to read, 1 to 1,000 per run. A URL without `https://` is read as `https://`. The same URL given twice is read and charged once. |
| `tableIndexes` | array | (all) | Only the nth `<table>` elements of the page, counted from 1. |
| `titleContains` | string | (none) | Only tables whose caption, aria-label or nearest heading contains this text (case does not matter). |
| `columnContains` | string | (none) | Only tables with a column name containing this text. |
| `minRows` | integer | `1` | Skip tables with fewer data rows. |
| `includeLayoutTables` | boolean | `false` | Also return layout tables (with `layoutReason`). |
| `stripFootnotes` | boolean | `true` | Remove footnote marks. |
| `includeTextValues` | boolean | `true` | Also return `rowsText` (every cell as written). |
| `includeCsv` | boolean | `false` | Also return each table as RFC 4180 CSV in `csv`. |
| `includeLinks` | boolean | `false` | Also return `links`: the first link in each cell, per row and column. |
| `maxRowsPerTable` | integer | `10000` | Upper limit of data rows per table (1 to 100,000); the rest is counted in `rowsTruncated`. |
| `onlyChanges` | boolean | `false` | Monitor mode: return only pages where a selected table gained or lost rows since the last run with the same `watchName`. |
| `watchName` | string | (none) | Name of the remembered state (letters, digits, `.`, `-`, `_`; up to 40). Setting it fills `changeType` and `changes`. |
| `resetMonitoringState` | boolean | `false` | Forget what this watch remembered. |
| `maxConcurrency` | integer | `4` | Pages read in parallel, 1 to 8. |
| `requestTimeoutSecs` | integer | `20` | Time limit for one request, 5 to 60 seconds. HTTP 429, 500, 502, 503 and 504 are asked again up to two more times. |

### Output

One row per URL. For each returned table:

| Field | Meaning |
|---|---|
| `index` | Position of this `<table>` on the page, counted from 1. |
| `title`, `caption`, `heading` | The `<caption>` (or `aria-label`), and the nearest heading above the table. `title` is the first of these that exists. |
| `headerSource`, `headerRowCount` | Where the column names came from: `thead`, `th-rows` (leading rows made of `<th>` cells), `first-row` (a table without `<th>`: the first row is used only when every cell in it is filled, not a number and unique, and a column below it is numeric), or `none` (names are `column_1`, `column_2`, ...). |
| `columns` | `name`, `type` (`number`, `text` or `empty`), `unit`, `currency`, `decimalSeparator`, `approximateCells`, `nonEmptyCells`, `numberCells`, `headerParts`. |
| `rows` | One object per data row, keyed by column name. Numbers in number columns; `null` for empty cells. |
| `rowsText` | The same rows with every cell as written on the page. |
| `spanCopies`, `spanCopyCount` | Cells filled from a merged cell (up to 500 listed). |
| `sectionRows`, `footerRowCount` | Group rows turned into the `Section` column; rows that came from `<tfoot>` (they are the last rows). |
| `emptyRowsDropped`, `repeatedHeaderRowsDropped`, `unparsedNumberCells`, `footnotesRemoved`, `rowsTruncated` | What was left out or changed, counted. |
| `gridRows`, `gridColumns` | Size of the table as laid out, header rows included. |
| `csv`, `links` | Only when `includeCsv` / `includeLinks` is on. |

Column order: read it from `columns`. Apify's dataset may list object keys that contain a dot (such as `Pos.` or `Fert. Rate`) after the other keys in `rows`; the values are unchanged.

Row fields: `tablesFound` (all `<table>` elements on the page), `tablesReturned`, `otherTables` (tables not returned, with the reason), `likelyNeedsJavaScript` / `jsEvidence`, `pageTitle`, `htmlBytesRead`, `htmlTruncated`, and in monitor mode `changeType` and `changes`.

#### Rows that are not charged

| `status` | Meaning |
|---|---|
| `no-tables` | The HTML has no `<table>` element. The note explains that tables drawn by JavaScript cannot be read by this Actor (this is not "the page has no table"). |
| `no-data-tables` | The page has `<table>` elements but none with data rows to return: layout tables, tables hidden on the page, or tables with header rows only (their rows are usually added by JavaScript). Each is listed in `otherTables` with the reason. |
| `no-matching-tables` | The page has data tables, but none matches your selection; all of them are listed in `otherTables`. |
| `robots-disallowed` / `robots-unreachable` | The site's robots.txt does not allow this address, or could not be read (RFC 9309: treated as disallowed). The page was not requested. |
| `login-required` | The page needs a sign-in (401 or a redirect to a sign-in page). |
| `blocked` | The site refused the request or showed a check page, including check pages sent with HTTP 200 or 202. |
| `not-found` / `http-error` / `unreachable` / `unreadable` / `not-html` / `bad-input` | As named; the note says why. |
| `too-large` | The page was read, but even one of its tables is too large for one dataset row (9 MB), for example a table with tens of thousands of columns. Take it with `tableIndexes` and a lower `maxRowsPerTable`. |
| `budget-reached` / `no-change` | The run's maximum charge was reached / monitor mode found no change. |

### Monitor mode

Set `watchName` (and `onlyChanges` to get only changed pages). The first run returns every page as `first-check`. Later runs compare each selected table row by row with what the watch remembered. Tables are recognised by their title, heading and column names (not by position), so a new layout table elsewhere on the page does not shift anything. A changed page returns only the tables that changed, and `changes: [{ tableIndex, title, change, rowsAdded, rowsRemoved, addedRows }]` where `change` is `rows-changed`, `table-added` or `table-removed` (up to 200 added rows are listed). A changed value in an existing row counts as one row removed and one added.

A change is re-read 1.5 seconds later and reported only if the table is the same in both reads; a table that differs between two reads a moment apart (for example a live price ticker) is not reported that time and is compared again in the next run. A page read only up to the 5 MB limit never reports tables as removed. If you change the table selection, each page starts over as `new`.

### Pricing

Pay per event:

- **Page with tables returned**: charged once per URL that comes back with `status: "ok"` (at least one table returned, however many tables it has). Rows with any other status are free.
- **Run start**: charged once per run that returns at least one page, or, in monitor mode, once per run that read and compared pages (even if nothing changed). A run that returns nothing is not charged at all.

If a run's maximum total charge has no room for the start fee plus one page, the Actor requests nothing and charges nothing.

### Calling it from code

```bash
curl -X POST "https://api.apify.com/v2/acts/neverempty~html-table-to-json/run-sync-get-dataset-items?token=<YOUR_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"urls":["https://en.wikipedia.org/wiki/List_of_countries_by_GDP_(nominal)"],"columnContains":"IMF","includeTextValues":false}'
```

AI agents can call it through the Apify MCP server (`https://mcp.apify.com`) as `neverempty/html-table-to-json`.

### Limits

- Runs use 1 GB of memory (also the minimum), so that a 5 MB page of small cells fits; pages with large tables are turned into rows one at a time.
- Up to 1,000 URLs per run; up to 5 MB of HTML and 400,000 table cells per page; up to 100,000 rows per table; one dataset row per page is kept under Apify's 9 MB item limit (CSV, then `rowsText`, then the last rows of the largest tables are left out first, and the note says so).
- Tables built from `<div>` elements (`role="grid"`) are not read; `no-tables` rows mention them when the HTML has them.
- In monitor mode, avoid two overlapping schedules with the same `watchName`.

### Support

Something missing or wrong? Open an issue in the Issues tab with the URL, the table and what you expected. This Actor is not affiliated with Wikipedia or any site named on this page.

# Actor input Schema

## `urls` (type: `array`):

Web pages whose HTML tables you want, one per line (1 to 1,000 per run). A URL without http:// or https:// is read as https://. The same URL given twice is read and charged once. If this is empty, the example page https://en.wikipedia.org/wiki/List_of_countries_and_dependencies_by_population is used.

## `tableIndexes` (type: `array`):

Return only the nth <table> elements of the page, counted from 1 in page order (the same numbering as the index field of each table and of otherTables). Example: 1 and 3. Empty means every data table.

## `titleContains` (type: `string`):

Return only tables whose caption, aria-label or nearest heading above the table contains this text (case does not matter). Example: GDP.

## `columnContains` (type: `string`):

Return only tables that have a column whose name contains this text (case does not matter). Example: population.

## `minRows` (type: `integer`):

Skip tables with fewer data rows than this (1 to 100,000). Skipped tables are listed in otherTables.

## `includeLayoutTables` (type: `boolean`):

Also return tables that are used for page layout rather than data (one row or one column without header cells, only images, cells holding other tables or long paragraphs, role="presentation", navigation boxes). Off by default; they are listed in otherTables with the reason.

## `stripFootnotes` (type: `boolean`):

Remove footnote marks from cells and captions: Wikipedia-style \[1], \[a], \[note 3], \[citation needed], and <sup> reference marks (class reference or footnote, or text like \[2], \*, †). The number of marks removed is in footnotesRemoved. Turn off to keep the cell text exactly as shown.

## `includeTextValues` (type: `boolean`):

Return rowsText next to rows: the same rows with every cell as the text shown on the page (for example "$1,234.50" where rows has 1234.5). Turn off for smaller output.

## `includeCsv` (type: `boolean`):

Add a csv field to each table: RFC 4180 CSV with the column names as the first line and the cells as written.

## `includeLinks` (type: `boolean`):

Add a links field to each table: for each row, the first link (absolute URL) found in each cell, keyed by column name.

## `maxRowsPerTable` (type: `integer`):

Return at most this many data rows per table (1 to 100,000). Rows left out are counted in rowsTruncated.

## `onlyChanges` (type: `boolean`):

Return only pages where a selected table gained or lost rows since the last run with the same watch name, with only the tables that changed and the added rows in changes (plus pages new to the watch). The first run returns every page as the starting point. A change is re-read after 1.5 seconds and reported only if the table is the same in both reads; a table that differs between two reads a moment apart (live data) is not reported that time and is compared again next run. A run in which nothing changed returns a free row saying so and charges only the run start fee.

## `watchName` (type: `string`):

Name of the remembered state used to compare runs (letters, digits, dot, dash, underscore; up to 40). Setting it (or turning on monitor mode) fills changeType and changes. Use a different name for each list of pages you track on its own schedule. With monitor mode on and no name, the name "default" is used.

## `resetMonitoringState` (type: `boolean`):

Start this watch over: forget the remembered tables before this run, so every page is returned as a first check.

## `maxConcurrency` (type: `integer`):

How many pages are read in parallel (1 to 8).

## `requestTimeoutSecs` (type: `integer`):

How long to wait for one page to answer (5 to 60 seconds). HTTP 429, 500, 502, 503 and 504 are asked again up to two more times; a page that does not answer in time is asked once more.

## Actor input object example

```json
{
  "urls": [
    "https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue"
  ],
  "minRows": 1,
  "includeLayoutTables": false,
  "stripFootnotes": true,
  "includeTextValues": true,
  "includeCsv": false,
  "includeLinks": false,
  "maxRowsPerTable": 10000,
  "onlyChanges": false,
  "resetMonitoringState": false,
  "maxConcurrency": 4,
  "requestTimeoutSecs": 20
}
```

# Actor output Schema

## `results` (type: `string`):

One row per page: every data table on the page as JSON (column names from the header rows, one object per row, numbers as numbers, the cells as written in rowsText), with each table's title, position, row and column counts, column types and the merged cells that were filled in. Tables not returned (layout, not selected) are listed with the reason. A page that robots.txt does not allow, that needs a sign-in, that shows a check page, or whose tables are drawn by JavaScript comes back as a free row that says why.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "urls": [
        "https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/html-table-to-json").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "urls": ["https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue"] }

# Run the Actor and wait for it to finish
run = client.actor("neverempty/html-table-to-json").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "urls": [
    "https://en.wikipedia.org/wiki/List_of_largest_companies_by_revenue"
  ]
}' |
apify call neverempty/html-table-to-json --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/html-table-to-json"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/j5veOHOFnrEdkjDBE/builds/Lgfds05rlvvyALCAa/openapi.json
