Markdown API
Convert any webpage to clean, structured Markdown — navigation, ads and boilerplate stripped. Optionally split it into chunks ready to embed, return only the passages that answer a question, or pull out the page's structure, all from one request.
v3.0 enables smart defaults — auto_proxy, auto_render, and retry are all on by default. The best proxy and rendering strategy is chosen automatically for each target domain.
Endpoint
GET https://opengraph.io/api/3.0/markdown/{encoded_url}?app_id=YOUR_APP_IDParameters
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| encoded_url | string | Required. URL-encoded target URL |
Query Parameters
Grouped by what you are trying to do. If all you want is the page as Markdown, you only need app_id — everything else is optional.
Getting the page
| Parameter | Type | Default | Description |
|---|---|---|---|
| app_id | string | - | Required. Your API key |
| only_main_content | boolean | true | Strip navigation, sidebars, and footers — return only the primary content |
| include_tags | string | - | Comma-separated CSS selectors to keep (e.g. article,main,section) |
| exclude_tags | string | - | Comma-separated CSS selectors to drop (e.g. nav,.sidebar,footer) |
| max_chars | integer | - | Cap the Markdown at this many characters. When the cap is hit, the response says so rather than returning a short body silently |
Choosing what comes back
By default the response body is the Markdown. Ask for format=json and you get an envelope instead, where each section can be switched off if you do not use it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| format | string | markdown | markdown returns a plain text/markdown body. json returns the envelope described below |
| include_markdown | boolean | true | Include the document itself. Set false when you only want chunks or structure. format=json only |
| include_metadata | boolean | true | Include the page metadata block. format=json only |
| include_chunks | boolean | true | Include the chunk array when chunking is on. Turn off to get chunk counts without the text |
| include_ai_safety | boolean | true | Include the ai_safety block when ai_sanitize=true |
Splitting the page for embedding
Set chunking=true and the page comes back already split into pieces sized for a vector database. Each piece carries the heading path it came from, so a chunk retrieved on its own still knows which section of the page it belongs to — which a naive character split cannot tell you.
| Parameter | Type | Default | Description |
|---|---|---|---|
| chunking | boolean | false | Split the document into chunks. Requires format=json |
| chunk_size | integer | 2000 | Target chunk size in characters (200–20,000). Roughly 4 characters per token |
| chunk_overlap | integer | 0 | Characters repeated from the end of the previous chunk, so a sentence split across a boundary still appears whole in one of them. At most half of chunk_size |
| heading_aware | boolean | true | Start a new chunk at each section heading instead of splitting purely by size |
| heading_aware_level | integer | 2 | Which heading level starts a new chunk (1–6). 2 means every H2 |
| max_chunks | integer | 500 | Most chunks to return (1–2,000). If the page produced more, the response says so and reports the full count |
Finding the part that answers a question
Send a question with the request and you get back only the passages that match it, each with a relevance score, instead of the whole page. This is keyword matching against the words on the page — it is fast and free, and it makes no model call. It is not embedding-based semantic search, and it is a different feature from the Query API, which asks a model to answer in prose.
| Parameter | Type | Default | Description |
|---|---|---|---|
| query | string | - | The question or keywords to match. Implies chunking. Use the words the page would use. Up to 512 characters |
| query_top_k | integer | 5 | How many matching passages to return |
Getting the page's structure
Ask for the shape of the page rather than its prose — useful for crawling, auditing, or building a table of contents without parsing the HTML yourself.
| Parameter | Type | Default | Description |
|---|---|---|---|
| include_headings | boolean | false | Return the heading outline, plus counts of tables and code blocks found |
| include_links | boolean | false | Return every link from the main content, resolved to an absolute URL |
| include_images | boolean | false | Return every image with its source and alt text |
Controlling the fetch
These behave the same way across our endpoints. On v3 the first three are on by default, so most callers never set them.
| Parameter | Type | Default | Description |
|---|---|---|---|
| auto_render | boolean | true | Detect a JavaScript app shell and re-fetch it rendered. Not billed as a render |
| auto_proxy | boolean | true | Automatically select a proxy for domains known to need one |
| retry | boolean | true | Retry a failed fetch, escalating to a higher proxy tier each attempt |
| max_retries | integer | 4 | Attempts before giving up (1–4) |
| retry_escalate | boolean | true | Whether each retry moves up a proxy tier, or stays on the one you chose |
| full_render | boolean | false | Always render in a browser first. Use when you need a render guaranteed rather than detected. Adds 5–10 seconds and is billed |
| wait_for_selector | string | - | Wait for this CSS selector to appear before reading the page. Turns rendering on by itself |
| scroll_to_bottom | boolean | false | Scroll down to trigger lazy-loaded content. Turns rendering on by itself |
| load_more_selector | string | - | Click this selector up to 3 times to page in more content. Turns rendering on by itself |
| use_proxy / use_premium / use_superior | boolean | false | Force a specific proxy tier. Premium and superior require a plan that includes them |
| proxy_country | string | - | Two-letter country code to fetch from, e.g. de. Only applies alongside a proxy |
| cache_ok | boolean | true | Allow a cached result |
| max_cache_age | integer | - | Maximum age of an acceptable cached result, in milliseconds |
| accept_lang | string | - | Accept-Language header sent to the page, e.g. en-US |
Checking the page for prompt injection
Web pages can contain text written to hijack a model that reads them. Turn this on and the response tells you what was found and how risky it looks.
| Parameter | Type | Default | Description |
|---|---|---|---|
| ai_sanitize | boolean | false | Scan the page for prompt-injection attempts. Costs 1 extra credit |
| ai_sanitize_mode | string | sanitize | What to do when something is found (only applies when ai_sanitize=true). sanitize — strip the risky content and return cleaned Markdown plus an ai_safety summary. warn — return the original Markdown with the summary attached. block — return a 422 instead of Markdown when the risk is high. Invalid values fall back to sanitize |
Response Headers
| Header | Present | Description |
|---|---|---|
| X-Request-Id | Always | Unique ID for this request — also included as request_id in every JSON body (success or error). Use it when reporting an issue. |
| X-BILLING-REQUESTS | Always | Credits billed for this request. Reflects the proxy tier that actually succeeded when retry escalated tiers, not just the tier initially requested. |
| X-Markdown-Character-Count | format=markdown only | Length of the returned Markdown body, in characters. |
| X-Markdown-Truncated / X-Markdown-Truncation-Reason | Only when truncated | Set when max_chars was exceeded and the body was cut off. |
| X-AI-Safety-Score / X-AI-Safety-Level | Only with ai_sanitize=true | Risk score (0–1) and level (low/medium/high) for the fetched page, in format=markdown mode. In format=json mode this is the ai_safety object instead. |
Example Request
# Default: plain text/markdown body
curl "https://opengraph.io/api/3.0/markdown/https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FWeb_scraping?app_id=YOUR_APP_ID"
# format=json: structured envelope with metadata, usage, and debug info
curl "https://opengraph.io/api/3.0/markdown/https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FWeb_scraping?app_id=YOUR_APP_ID&format=json"
# Split into chunks ready to embed, each keeping its heading path
curl "https://opengraph.io/api/3.0/markdown/https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FWeb_scraping?app_id=YOUR_APP_ID&format=json&chunking=true&chunk_size=1200&chunk_overlap=100"
# Return only the passages that answer a question
curl -G "https://opengraph.io/api/3.0/markdown/https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FWeb_scraping?app_id=YOUR_APP_ID&format=json" \
--data-urlencode "query=legal issues around scraping" \
-d query_top_k=3
# Structure only — no document body
curl "https://opengraph.io/api/3.0/markdown/https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FWeb_scraping?app_id=YOUR_APP_ID&format=json&include_markdown=false&include_headings=true&include_links=true"Raw Markdown Response (default)
When format=markdown (default), the response body is unchanged from previous versions of this endpoint — plain text with Content-Type: text/markdown. The headers below are additive.
Content-Type: text/markdown
X-Request-Id: 3f1a9c2e-8b4d-4e7a-9c1a-2d6f8e0b5a7c
X-BILLING-REQUESTS: 1
X-Markdown-Character-Count: 4820
# Only present when max_chars was exceeded:
X-Markdown-Truncated: true
X-Markdown-Truncation-Reason: max_chars_exceeded
# Only present when ai_sanitize=true:
X-AI-Safety-Score: 0.12
X-AI-Safety-Level: lowJSON Response (format=json)
When format=json, the response is a structured envelope. ai_safety is only present when ai_sanitize=true was requested — it's omitted entirely otherwise, rather than being sent as null.
{
"markdown": "# Example Article\n\nMain content here...",
"metadata": {
"title": "Example Article Title",
"description": "A short description from the page's meta tags.",
"canonical_url": "https://example.com/article",
"site_name": "Example Site",
"image": "https://example.com/og-image.jpg",
"language": "en",
"final_url": "https://example.com/article"
},
"usage": {
"character_count": 4820,
"estimated_token_count": 1205,
"truncated": false,
"truncation_reason": null,
"max_chars": null
},
"debug": {
"full_render_used": false,
"auto_render_triggered": false,
"proxy_used": null,
"retry_used": false,
"retry_attempts": 1,
"final_status_code": 200
},
"request_id": "3f1a9c2e-8b4d-4e7a-9c1a-2d6f8e0b5a7c"
}Adding ai_sanitize=true attaches an ai_safety object:
"ai_safety": {
"risk_score": 0.12,
"risk_level": "low",
"action_taken": "sanitize",
"signals": [],
"recommendation": "No action needed."
}Chunks (chunking=true)
Each chunk carries the heading path it came from, its own counts, and its position in the document — so a chunk retrieved later still knows where it sat on the page. usage.chunk_count is how many the page produced in total, which can be larger than the number returned.
"chunks": [
{
"index": 0,
"text": "Install the client with your package manager...",
"heading_path": ["Getting Started", "Installation"],
"character_count": 1840,
"estimated_token_count": 460,
"start_offset": 0,
"end_offset": 1840,
"overlap_character_count": 0,
"split_mid_block": false
}
],
"usage": {
"chunk_count": 12,
"chunks_truncated": false
}Matched passages (query=...)
Sending a query returns the same chunk shape with a relevance_score from 0 to 1 added, ordered best match first. The index stays the chunk's position in the page, not its rank — so passage 1 in the list may be chunk 7 of the document.
"chunks": [
{
"index": 7,
"text": "Every plan includes a monthly credit allowance...",
"heading_path": ["Pricing", "Usage limits"],
"relevance_score": 0.87,
"character_count": 920,
"estimated_token_count": 230
}
]Structure (include_headings / include_links / include_images)
Returned from the same fetch that produced the Markdown — no second request, no parsing the HTML yourself. Long lists are capped, and the response says so.
"headings": [
{ "level": 1, "text": "Getting Started" },
{ "level": 2, "text": "Installation" }
],
"links": [
{
"url": "https://example.com/docs/install",
"text": "installation guide",
"rel": null,
"internal": true
}
],
"images": [
{ "url": "https://example.com/diagram.png", "alt": "Architecture diagram" }
],
"tables_detected": 2,
"code_blocks_detected": 5When part of the response can't be produced
Splitting, ranking and structure extraction are optional steps. If one of them fails, the request does not fail — you still get the document, and usage tells you which step did not finish. Check these if a section you asked for is missing.
| Field | Means |
|---|---|
| usage.chunk_error | Chunking or ranking could not complete. The document is still returned |
| usage.structure_error | Heading, link or image extraction could not complete |
| usage.chunks_truncated | More chunks existed than max_chunks allowed. usage.chunk_count has the real total |
| usage.headings_truncated / links_truncated / images_truncated | That list hit its cap and was cut short |
| usage.truncated / truncation_reason | The Markdown itself was cut off, normally because max_chars was reached |
Structured Errors
Every error response — regardless of format — is JSON with an error object and the same request_id that was returned in X-Request-Id. Check error.code, not the HTTP status alone, since several distinct failure modes can share a status code.
{
"error": {
"code": -2201,
"message": "Invalid max_chars value. Must be a positive integer."
},
"request_id": "3f1a9c2e-8b4d-4e7a-9c1a-2d6f8e0b5a7c"
}| HTTP Status | error.code | Meaning |
|---|---|---|
| 400 | -2200 | Invalid format — must be markdown or json |
| 400 | -2201 | Invalid max_chars — must be a positive integer |
| 400 | varies | The target URL could not be fetched (DNS failure, timeout, blocked, etc.) |
| 404 | -4000 | The fetch succeeded but returned no HTML content |
| 422 | -4001 | Blocked by ai_sanitize_mode=block — high prompt-injection risk detected. The response also includes an ai_safety object explaining why. |
| 500 | -4002 | HTML-to-Markdown conversion failed unexpectedly |
Use Cases
- LLM and AI content ingestion pipelines
- RAG (Retrieval-Augmented Generation) data preparation
- Content migration between platforms
- Documentation scraping and archival
- Clean text extraction for NLP processing
- Safe ingestion of untrusted external pages with AI safety sanitization