{"openapi":"3.1.0","info":{"title":"url2md","version":"1.4.0","description":"PDFs, JavaScript-rendered pages and ordinary HTML, returned as clean Markdown. Follows robots.txt (RFC 9309) for the user-agent token `url2md`. Paid routes use the x402 protocol (v2), USDC on Base. When a page charges an x402 toll, url2md pays it and passes the cost through. GET / reports the current price, network and payTo address.\n\nWhat is kept: The Markdown is never stored: it is converted, returned, and no copy is written anywhere. What is kept is the request itself. The URL you asked for, your IP and your User-Agent stay in Cloudflare's request logs for 7 days. Every paid call also writes a ledger row — the URL, your payer address, the payment's nonce and transaction, the client name and User-Agent your request sent, and what the call returned — and that row is kept indefinitely, because it is the record of what you were charged and the thing the ledger is reconciled against on-chain. Payments are on a public blockchain, so payer, amount and transaction are public and permanent regardless. A signed-in call through the hosted MCP connector writes a row too — the URL, your account id and the outcome, at $0 — kept for 90 days. If a URL is itself a secret, that is worth knowing before you send it.","contact":{"email":"hello@url2md.io"},"x-guidance":"Reach for url2md when you need a web page or PDF as Markdown and have no reader service you can call unattended: no key, or a free allowance that has run out and would need a human to sign up for more. url2md needs no account and no key. It takes a per-call payment over x402 instead, so an agent can use it on its own. Good for: an article, documentation, a JavaScript-rendered single-page app, or a PDF you want as context. Send the URL, get Markdown plus title, final URL after redirects, word and token counts. Use render=auto, the default, and let the service decide whether a headless browser is needed. Use render=static when you know the page is server-rendered and want it cheaper and faster, and render=browser only when you know it is not. Not for: JSON or other API responses (it converts documents and refuses content types it cannot render), crawling a site (one URL per call, and robots.txt is followed, so a disallowed URL is refused), or pages behind a login. If the page charges an x402 toll, url2md answers 402 again with a quote covering that toll and pays the origin for you once your payment settles."},"servers":[{"url":"https://url2md.io"}],"paths":{"/":{"get":{"security":[],"operationId":"describe","summary":"Service description (JSON) or landing page (HTML)","description":"Content negotiation on Accept. A client that ranks text/html above application/json (a browser) gets the landing page. Everything else (curl, fetch and SDKs send */* or application/json) gets the JSON description with pricing, network, payTo and policies. ?format=json|html overrides the header.","parameters":[{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["json","html"]},"description":"Force one rendering regardless of Accept"}],"responses":{"200":{"description":"Service metadata (JSON) or the landing page (HTML); Vary: Accept","content":{"application/json":{"schema":{"type":"object"}},"text/html":{"schema":{"type":"string"}}}}}}},"/health":{"get":{"security":[],"operationId":"health","summary":"Dependency health","description":"Checks what a caller depends on and reports each one: the payment configuration, the ledger KV namespace, the upstream-toll passthrough settings, whether the x402 facilitator is reachable, and whether the browser and AI bindings are present. It fails closed — anything not confirmed makes `ok` false and the status 503 — and it never converts, renders, calls Workers AI, or asks the facilitator to verify or settle, because this route is free and unauthenticated. It publishes no configured value: no address, price, balance, key or token. The facilitator check is reachability only (an unauthenticated probe; a 401 is a pass) and its answer is shared for 30 seconds.","responses":{"200":{"description":"Every check passed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}},"503":{"description":"At least one check failed or could not be confirmed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}},"/.well-known/x402":{"get":{"security":[],"operationId":"wellKnownX402","summary":"x402 discovery manifest","description":"The out-of-band capability manifest (draft-hawkins-x402-dns-discovery-03, well-known suffix `x402`): which network this deployment settles on, which asset, which address, what a call costs and which paths are gated — everything a directory needs to catalogue the service without first making a paid request. Every value is built from the same route declarations the payment gate prices its 402 from, and the `accepts` array of each resource is the offer the live PAYMENT-REQUIRED header carries. Unauthenticated, cacheable, and it publishes nothing that GET / does not already. A deployment that takes no x402 payment has no manifest and answers 404.","responses":{"200":{"description":"The manifest","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"This deployment takes no x402 payment, so it publishes no manifest (NOT_FOUND)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/.well-known/security.txt":{"get":{"security":[],"operationId":"securityTxt","summary":"Where to send a security report","description":"The RFC 9116 security contact: an address to send a vulnerability report to, the language a report can be written in, and the canonical location of this file. Served as text/plain by the Worker, anonymously and free. It is here because this is the path every scanner and every researcher checks first, and it reads nothing about the deployment: no configuration, no key, no payment state.","responses":{"200":{"description":"The security.txt file, as text/plain","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/openapi.json":{"get":{"security":[],"operationId":"openapi","summary":"This document","responses":{"200":{"description":"OpenAPI 3.1 document","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/llms.txt":{"get":{"security":[],"operationId":"llmsTxt","summary":"The service as Markdown, for a language model","description":"The /llms.txt convention: what this service is, what it costs, its endpoints, two example calls, a link to this document, its limits and a contact — as Markdown. Built from the same description GET / returns as JSON, so it can state nothing of its own.","responses":{"200":{"description":"Markdown, as text/plain","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/privacy":{"get":{"security":[],"operationId":"privacy","summary":"What is kept, for how long, and who else sees it (the privacy policy)","description":"One page a reader or a directory reviewer can link: the same retention sentence GET / publishes, verbatim, plus what signing in to the hosted MCP connector keeps (the numeric GitHub id only) and for how long — every number read from the constant that enforces it. No script, no tracking.","responses":{"200":{"description":"HTML","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/og.png":{"get":{"security":[],"operationId":"ogImage","summary":"Link-preview image (PNG)","description":"The 1200×630 image named by the landing page's og:image and twitter:image. A raster because link-preview renderers do not accept SVG. Free, immutable, cached for a year.","responses":{"200":{"description":"PNG, 1200×630","content":{"image/png":{"schema":{"type":"string","format":"binary"}}}}}}},"/favicon.svg":{"get":{"security":[],"operationId":"favicon","summary":"The service mark, at the path a crawler probes","description":"Byte-for-byte the same response as /icon.svg. A discovery crawler asking for / is served JSON, not the landing page, so it never sees the <link rel=\"icon\"> and falls back to looking for a favicon at the root.","responses":{"200":{"description":"SVG, 64×64 viewBox","content":{"image/svg+xml":{"schema":{"type":"string"}}}}}}},"/icon.svg":{"get":{"security":[],"operationId":"icon","summary":"The service mark (SVG)","description":"The favicon and the landing page's <link rel=\"icon\">, for readers that render the mark. Free, immutable, cached for a year.","responses":{"200":{"description":"SVG, 64×64 viewBox","content":{"image/svg+xml":{"schema":{"type":"string"}}}}}}},"/icon.png":{"get":{"security":[],"operationId":"iconPng","summary":"The service mark (PNG)","description":"The same mark as a 256×256 raster, rendered from /icon.svg at build time. This is what the Bazaar declaration's resource.iconUrl points at: a directory fetches the bytes and re-hosts them, and takes rasters only. Free, immutable, cached for a year.","responses":{"200":{"description":"PNG, 256×256","content":{"image/png":{"schema":{"type":"string","format":"binary"}}}}}}},"/stats":{"get":{"operationId":"stats","summary":"Operator view of the ledger (not public)","description":"A read-only HTML page: paid calls, revenue, tolls paid out, hot-wallet balance, errors and rows that owe a refund, for the last 30 days, read from the ledger KV at request time. Requires the STATS_TOKEN secret as Authorization: Bearer <token>, or HTTP Basic with the token as the password. A deployment with no STATS_TOKEN answers 404: the route is off, not open.","security":[{"statsToken":[]},{"statsBasic":[]}],"responses":{"200":{"description":"The stats page","content":{"text/html":{"schema":{"type":"string"}}}},"401":{"description":"No credential, or the wrong one (UNAUTHORIZED). The answer carries WWW-Authenticate: Basic so a browser prompts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"This deployment has no STATS_TOKEN set, so the route is off (STATS_DISABLED)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/stats/test-page":{"post":{"operationId":"statsTestPage","summary":"Send one test message down the alert channel (not public)","description":"Sends one message to the operator's push channel because the operator asked for it, so a channel that reaches nobody is discoverable before the hot wallet needs it. Same STATS_TOKEN credential and the same fail-closed refusals as GET /stats. One test per 10 minutes per network. `sent`/`accepted` mean the push service took the message; no push service confirms that a device received it, which is why `deliveryConfirmed` is always false. The destination is never returned.","security":[{"statsToken":[]},{"statsBasic":[]}],"responses":{"200":{"description":"The channel accepted the message","content":{"application/json":{"schema":{"type":"object","properties":{"sent":{"type":"boolean"},"accepted":{"type":"boolean"},"deliveryConfirmed":{"type":"boolean"},"at":{"type":"string"},"network":{"type":"string"},"note":{"type":"string"}}}}}},"401":{"description":"No credential, or the wrong one (UNAUTHORIZED).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"This deployment has no STATS_TOKEN set, so the route is off (STATS_DISABLED)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Nothing was sent: no channel configured, the throttle refused, or the channel refused the message","content":{"application/json":{"schema":{"type":"object","properties":{"sent":{"type":"boolean"},"reason":{"type":"string"}}}}}}}}},"/mcp":{"post":{"operationId":"mcp","summary":"The hosted MCP connector (Streamable HTTP)","description":"Model Context Protocol over Streamable HTTP, POST only: JSON-RPC in, JSON or an event stream out. Protected by the OAuth 2.1 server this document describes: a request without a valid bearer token answers 401 with WWW-Authenticate: Bearer resource_metadata=\"<origin>/.well-known/oauth-protected-resource/mcp\" (RFC 9728), which is how a client finds the authorization server. One tool, fetch_markdown, with the same input and output as the stdio server @url2md-io/mcp. Free to the signed-in user and allowance-bounded (calls per rolling 30 days, browser renders among them, and a service-wide daily breaker: ALLOWANCE_EXHAUSTED as a tool error, never a 5xx). It never pays an upstream x402 toll: a page that charges one is a tool error pointing at POST /v1/md. Add it in claude.ai as a custom connector with the URL of this path.","security":[{"mcpOAuth":["url2md:fetch"]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"A JSON-RPC 2.0 request or batch"}}}},"responses":{"200":{"description":"A JSON-RPC response, as application/json or text/event-stream","content":{"application/json":{"schema":{"type":"object"}},"text/event-stream":{"schema":{"type":"string"}}}},"401":{"description":"No bearer token, or an invalid, expired or revoked one. WWW-Authenticate names the protected-resource metadata.","headers":{"WWW-Authenticate":{"schema":{"type":"string"}}}},"405":{"description":"GET and DELETE: the connector is stateless and answers POST only"}}}},"/authorize":{"get":{"security":[],"operationId":"oauthAuthorize","summary":"OAuth 2.1 authorization endpoint: the consent page","description":"For a browser in the middle of connecting, not for an agent. The client (registered at /register or carrying a Client ID Metadata Document) sends the user here with response_type=code, client_id, a registered redirect_uri, state, code_challenge and code_challenge_method=S256 — PKCE is required of every client, public or confidential — and optionally scope and resource (which must be <origin>/mcp). The page names the client, the relying party and where the user will be sent back, and offers GitHub sign-in. The consent state travels in a signed, HttpOnly cookie valid for ten minutes. A request the provider refuses is answered per RFC 6749 §4.1.2.1: redirected to the registered redirect_uri with error, error_description, state and iss when the redirect URI itself was valid, else 400.","parameters":[{"name":"response_type","in":"query","required":true,"schema":{"type":"string","enum":["code"]}},{"name":"client_id","in":"query","required":true,"schema":{"type":"string"}},{"name":"redirect_uri","in":"query","required":true,"schema":{"type":"string","format":"uri"},"description":"One of the client's registered redirect URIs"},{"name":"state","in":"query","required":false,"schema":{"type":"string"}},{"name":"code_challenge","in":"query","required":true,"schema":{"type":"string"}},{"name":"code_challenge_method","in":"query","required":true,"schema":{"type":"string","enum":["S256"]}},{"name":"scope","in":"query","required":false,"schema":{"type":"string"},"description":"url2md:fetch, the one scope"},{"name":"resource","in":"query","required":false,"schema":{"type":"string","format":"uri"},"description":"RFC 8707; must be exactly <origin>/mcp when present"}],"responses":{"200":{"description":"The consent page","content":{"text/html":{"schema":{"type":"string"}}}},"302":{"description":"The request was refused: redirected to the client's registered redirect_uri with error, error_description, state and iss"},"400":{"description":"The client or its redirect URI is unknown, so no redirect is safe (error, error_description)","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"The connector is not enabled on this deployment (NOT_FOUND)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Sign-in is not configured on this deployment (server_error)","content":{"application/json":{"schema":{"type":"object"}}}}}},"post":{"security":[],"operationId":"oauthConsent","summary":"The consent form's submission","description":"The page's own form, posted back with the same query string it was served for. The hidden token must equal the state cookie (double submit), the state must verify and not be expired, and the live request must match the consent it was started with; PKCE is checked again. decision=deny returns the user to the client with error=access_denied. Otherwise the browser is sent to GitHub to sign in, or — on the Base Sepolia deployment only, when a test login is configured — a posted test_login and test_user complete the grant on the spot for the gauntlet.","requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"},"decision":{"type":"string","enum":["allow","deny"]},"test_login":{"type":"string"},"test_user":{"type":"string"}}}}}},"responses":{"302":{"description":"To GitHub's authorization page, or to the client's redirect_uri with a code (test login) or an error"},"400":{"description":"The form did not carry its state, the state did not verify, or the request does not match it (error, error_description)","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"The connector is not enabled on this deployment (NOT_FOUND)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Sign-in is not configured on this deployment (server_error)","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/oauth/github/callback":{"get":{"security":[],"operationId":"oauthGithubCallback","summary":"Where GitHub sends the user back","description":"The state cookie must be present and valid and its nonce must equal GitHub's state; the code is exchanged for a token that is used once, to read the user's numeric GitHub id, and not kept. The grant is completed for github:<id> and the user is sent back to the client's registered redirect_uri with the authorization code, state and iss. A failure at GitHub is an error redirect to the client (access_denied, server_error), never a page.","parameters":[{"name":"code","in":"query","required":false,"schema":{"type":"string"}},{"name":"state","in":"query","required":true,"schema":{"type":"string"}},{"name":"error","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"To the client's redirect_uri with code, state and iss — or with an error"},"400":{"description":"No valid consent state, or a state mismatch (error, error_description)","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"The connector is not enabled on this deployment (NOT_FOUND)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/token":{"post":{"security":[],"operationId":"oauthToken","summary":"OAuth 2.1 token endpoint","description":"Authorization-code exchange with PKCE (S256 only), refresh with rotation (the immediately previous refresh token stays valid until the new one is first used), and revocation (RFC 7009). application/x-www-form-urlencoded, per RFC 6749. Tokens are opaque and bound to the resource <origin>/mcp; a token for any other resource is refused with 401 invalid_token.","requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"access_token, token_type, expires_in, refresh_token, scope, resource","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"invalid_request, invalid_grant, invalid_client, unsupported_grant_type (RFC 6749 §5.2)","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/register":{"post":{"security":[],"operationId":"oauthRegister","summary":"Dynamic client registration (RFC 7591)","description":"Any client may register, public or confidential, as long as every redirect URI is https or plain-http loopback (127.0.0.0/8, ::1, localhost, port ignored). No whitelist of known clients. Registrations are capped per UTC day across every caller (429 temporarily_unavailable past the cap) and a registered client lives 90 days. Clients that carry their own metadata document (Client ID Metadata Documents) need not register at all.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["redirect_uris"],"properties":{"client_name":{"type":"string"},"redirect_uris":{"type":"array","items":{"type":"string","format":"uri"}},"token_endpoint_auth_method":{"type":"string","enum":["none","client_secret_basic","client_secret_post"]},"grant_types":{"type":"array","items":{"type":"string"}},"response_types":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"201":{"description":"The registered client: client_id, redirect_uris, and client_secret for a confidential client","content":{"application/json":{"schema":{"type":"object"}}}},"400":{"description":"invalid_client_metadata or invalid_redirect_uri","content":{"application/json":{"schema":{"type":"object"}}}},"429":{"description":"The day's registration cap is reached (temporarily_unavailable); Retry-After is set","content":{"application/json":{"schema":{"type":"object"}}}},"503":{"description":"Registration is not configured on this deployment, or its store could not be read (temporarily_unavailable)","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/.well-known/oauth-authorization-server":{"get":{"security":[],"operationId":"oauthAuthorizationServerMetadata","summary":"Authorization-server metadata (RFC 8414)","description":"issuer (this origin), authorization_endpoint (/authorize), token_endpoint (/token), registration_endpoint (/register), scopes_supported, response_types_supported, grant_types_supported, token_endpoint_auth_methods_supported (including none, for public clients), code_challenge_methods_supported (S256 only), revocation_endpoint, authorization_response_iss_parameter_supported (true), and client_id_metadata_document_supported.","responses":{"200":{"description":"The metadata document","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/.well-known/oauth-protected-resource":{"get":{"security":[],"operationId":"oauthProtectedResourceMetadataRoot","summary":"Protected-resource metadata (RFC 9728), root form","description":"The document a client falls back to when the 401 carried no resource_metadata pointer: resource (<origin>/mcp), authorization_servers (this origin), scopes_supported, bearer_methods_supported (header), resource_name. It describes /mcp and nothing else: no other path on this origin is protected by this authorization server, and no other path under /.well-known/oauth-protected-resource/ answers.","responses":{"200":{"description":"The metadata document","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/.well-known/oauth-protected-resource/mcp":{"get":{"security":[],"operationId":"oauthProtectedResourceMetadata","summary":"Protected-resource metadata (RFC 9728), path-inserted form","description":"The same document at the form the MCP specification and Claude try first, and the one the 401 challenge on /mcp names.","responses":{"200":{"description":"The metadata document","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/v1/md":{"get":{"operationId":"convertGet","summary":"Convert a URL to Markdown","parameters":[{"name":"url","in":"query","required":true,"schema":{"type":"string","format":"uri","maxLength":2048},"description":"Absolute http(s) URL to convert; percent-encode it in the query string. http and https only, no credentials in the URL, no private, loopback or internal hosts, and the port must be one Cloudflare proxies (80, 443, 8080, 8443, 8880, 2052/2053/2082/2083/2086/2087/2095/2096). robots.txt is honoured for the user-agent token url2md."},{"name":"render","in":"query","required":false,"schema":{"type":"string","enum":["auto","static","browser"],"default":"auto"},"description":"auto: static first, browser only when the page looks client-rendered. static: never use a browser. browser: always render in a headless browser."},{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["json","markdown"]},"description":"json: the whole result object. markdown: the markdown body alone, sent as text/markdown. Omit it and the format follows the Accept header (text/markdown → markdown), else json."}],"responses":{"200":{"description":"Converted content","headers":{"PAYMENT-RESPONSE":{"schema":{"type":"string"},"description":"x402 v2: base64 JSON settlement receipt ({success, transaction, network, payer}) when the route is paid"},"X-Url2md-Source":{"schema":{"type":"string","enum":["static","browser","pdf","origin-markdown","text"]}},"X-Url2md-Final-Url":{"schema":{"type":"string"}},"X-Url2md-Title":{"schema":{"type":"string"},"description":"percent-encoded"},"X-Url2md-Words":{"schema":{"type":"integer"}},"X-Url2md-Tokens-Estimate":{"schema":{"type":"integer"},"description":"rough token count of the markdown; sent on text/markdown responses (the JSON body carries tokensEstimate)"},"X-Url2md-Upstream-Toll":{"schema":{"type":"string"},"description":"USD the service paid the origin on your behalf (x402 passthrough); absent when no toll was paid"},"X-Url2md-Upstream-Valid-Before":{"schema":{"type":"string"},"description":"When the payment authorization the service signed for that toll expires (ISO 8601). The service bounds this itself rather than accepting whatever the origin asks for; absent when no toll was paid"},"X-Url2md-Block-Suspected":{"schema":{"type":"string"},"description":"what came back looks like a bot challenge or block page rather than the page; the markers that matched. Absent on an ordinary conversion"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConvertResult"}},"text/markdown":{"schema":{"type":"string"},"example":"# Example Domain\n\nThis domain is for use in illustrative examples in documents."}}},"400":{"description":"Invalid request or URL (INVALID_REQUEST, INVALID_URL, UNSUPPORTED_SCHEME, BLOCKED_HOST)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Payment required (x402 v2). The PAYMENT-REQUIRED header is base64 JSON: {x402Version: 2, accepts: [{scheme: 'exact', network, asset, amount, payTo, maxTimeoutSeconds, extra}], resource}. Retry with a signed authorization in PAYMENT-SIGNATURE. If the origin itself charges an x402 toll, a paid call answers 402 again with a quote (base price + toll + margin, extra.paymentFlow = 'upfront', extra.quote = the quote signed by the service, JSON body {quote, howToPay}); pay that quote as offered (client libraries echo extra.quote with the payment) and the page is returned with X-Url2md-Upstream-Toll. A 402 with a JSON error body and no PAYMENT-REQUIRED header is PAYMENT_REJECTED: the facilitator refused to settle your verified payment when the service was about to start a browser render; nothing was rendered or charged, sign a fresh payment and call again.","headers":{"PAYMENT-REQUIRED":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object"}}}},"403":{"description":"robots.txt disallows this URL for url2md (ROBOTS_DISALLOWED)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Upstream content, the rendered page, or the request body (16 KiB) exceeds its size limit (CONTENT_TOO_LARGE)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"Upstream content type cannot be converted (UNSUPPORTED_CONTENT_TYPE)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"No readable content (EMPTY_CONTENT)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (RATE_LIMITED). Per client IP: 60 requests to /v1/md per 60 seconds, of which at most 6 may be browser renders (render=browser, or render=auto on a page that needs one). error.details carries scope, limit and windowSeconds, and the response carries Retry-After. A browser render refused this way is not an error in render=auto: the static result is returned with a warning.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream failure (UPSTREAM_ERROR, UPSTREAM_STATUS, UPSTREAM_PAYMENT_REQUIRED, UPSTREAM_TOLL_TOO_HIGH, UPSTREAM_PAYMENT_FAILED, TOO_MANY_REDIRECTS, RENDER_FAILED, PDF_CONVERSION_FAILED). UPSTREAM_STATUS is one code for several different things and details.status says which: a 404 or 5xx is the page failing, while a 403 is usually the origin refusing url2md itself — a bot wall or WAF, not a page failure, and retrying it changes nothing. On a 429 the origin is asking for a slower pace and details.retryAfter carries its Retry-After header when it sent one. A block the origin serves with HTTP 200 is not an error at all: it is converted and charged, with blockSuspected and a warning saying so. After a settled payment (a toll quote, or a base payment settled before a browser render), any error carries details.callerCharged, details.ledgerId, details.ledgerStatus (needs_refund, toll_committed or charged_no_content), details.refundableUsd and details.committedTollUsd, details.upstreamTransaction, details.ledgerPersisted and the headers X-Url2md-Caller-Charged / X-Url2md-Ledger-Id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Temporarily unavailable (ROBOTS_UNREACHABLE, RENDER_UNAVAILABLE, PAYMENT_UNAVAILABLE, TOLL_FUNDING_UNAVAILABLE). TOLL_FUNDING_UNAVAILABLE means the page charges an x402 toll that url2md's hot wallet cannot fund right now, counting the authorizations it has already signed; it is refused before any payment settles, so nothing was charged, and details.retryAfterSeconds says when to try again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Upstream timed out (UPSTREAM_TIMEOUT): the fetch — robots.txt, every redirect hop and the body on one deadline — did not finish within 20 seconds. A browser render is bounded separately at 55 seconds (30 s navigation plus 25 s action and slack) and fails as RENDER_FAILED, so one call is bounded at 75 seconds; set your own timeout to 90 seconds. Giving up earlier does not cancel the call, and on a page that needed a browser the payment has already settled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"x-payment-info":{"price":{"mode":"fixed","currency":"USD","amount":"0.005000"},"protocols":[{"x402":{}}]}},"post":{"operationId":"convertPost","summary":"Convert a URL to Markdown (JSON body)","x-payment-info":{"price":{"mode":"fixed","currency":"USD","amount":"0.005000"},"protocols":[{"x402":{}}]},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","maxLength":2048,"description":"Absolute http(s) URL to convert; percent-encode it in the query string. http and https only, no credentials in the URL, no private, loopback or internal hosts, and the port must be one Cloudflare proxies (80, 443, 8080, 8443, 8880, 2052/2053/2082/2083/2086/2087/2095/2096). robots.txt is honoured for the user-agent token url2md."},"render":{"type":"string","enum":["auto","static","browser"],"default":"auto","description":"auto: static first, browser only when the page looks client-rendered. static: never use a browser. browser: always render in a headless browser."},"format":{"type":"string","enum":["json","markdown"],"description":"json: the whole result object. markdown: the markdown body alone, sent as text/markdown. Omit it and the format follows the Accept header (text/markdown → markdown), else json."}}}}}},"responses":{"200":{"description":"Converted content","headers":{"PAYMENT-RESPONSE":{"schema":{"type":"string"},"description":"x402 v2: base64 JSON settlement receipt ({success, transaction, network, payer}) when the route is paid"},"X-Url2md-Source":{"schema":{"type":"string","enum":["static","browser","pdf","origin-markdown","text"]}},"X-Url2md-Final-Url":{"schema":{"type":"string"}},"X-Url2md-Title":{"schema":{"type":"string"},"description":"percent-encoded"},"X-Url2md-Words":{"schema":{"type":"integer"}},"X-Url2md-Tokens-Estimate":{"schema":{"type":"integer"},"description":"rough token count of the markdown; sent on text/markdown responses (the JSON body carries tokensEstimate)"},"X-Url2md-Upstream-Toll":{"schema":{"type":"string"},"description":"USD the service paid the origin on your behalf (x402 passthrough); absent when no toll was paid"},"X-Url2md-Upstream-Valid-Before":{"schema":{"type":"string"},"description":"When the payment authorization the service signed for that toll expires (ISO 8601). The service bounds this itself rather than accepting whatever the origin asks for; absent when no toll was paid"},"X-Url2md-Block-Suspected":{"schema":{"type":"string"},"description":"what came back looks like a bot challenge or block page rather than the page; the markers that matched. Absent on an ordinary conversion"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConvertResult"}},"text/markdown":{"schema":{"type":"string"},"example":"# Example Domain\n\nThis domain is for use in illustrative examples in documents."}}},"400":{"description":"Invalid request or URL (INVALID_REQUEST, INVALID_URL, UNSUPPORTED_SCHEME, BLOCKED_HOST)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Payment required (x402 v2). The PAYMENT-REQUIRED header is base64 JSON: {x402Version: 2, accepts: [{scheme: 'exact', network, asset, amount, payTo, maxTimeoutSeconds, extra}], resource}. Retry with a signed authorization in PAYMENT-SIGNATURE. If the origin itself charges an x402 toll, a paid call answers 402 again with a quote (base price + toll + margin, extra.paymentFlow = 'upfront', extra.quote = the quote signed by the service, JSON body {quote, howToPay}); pay that quote as offered (client libraries echo extra.quote with the payment) and the page is returned with X-Url2md-Upstream-Toll. A 402 with a JSON error body and no PAYMENT-REQUIRED header is PAYMENT_REJECTED: the facilitator refused to settle your verified payment when the service was about to start a browser render; nothing was rendered or charged, sign a fresh payment and call again.","headers":{"PAYMENT-REQUIRED":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object"}}}},"403":{"description":"robots.txt disallows this URL for url2md (ROBOTS_DISALLOWED)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Upstream content, the rendered page, or the request body (16 KiB) exceeds its size limit (CONTENT_TOO_LARGE)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"415":{"description":"Upstream content type cannot be converted (UNSUPPORTED_CONTENT_TYPE)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"No readable content (EMPTY_CONTENT)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (RATE_LIMITED). Per client IP: 60 requests to /v1/md per 60 seconds, of which at most 6 may be browser renders (render=browser, or render=auto on a page that needs one). error.details carries scope, limit and windowSeconds, and the response carries Retry-After. A browser render refused this way is not an error in render=auto: the static result is returned with a warning.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Upstream failure (UPSTREAM_ERROR, UPSTREAM_STATUS, UPSTREAM_PAYMENT_REQUIRED, UPSTREAM_TOLL_TOO_HIGH, UPSTREAM_PAYMENT_FAILED, TOO_MANY_REDIRECTS, RENDER_FAILED, PDF_CONVERSION_FAILED). UPSTREAM_STATUS is one code for several different things and details.status says which: a 404 or 5xx is the page failing, while a 403 is usually the origin refusing url2md itself — a bot wall or WAF, not a page failure, and retrying it changes nothing. On a 429 the origin is asking for a slower pace and details.retryAfter carries its Retry-After header when it sent one. A block the origin serves with HTTP 200 is not an error at all: it is converted and charged, with blockSuspected and a warning saying so. After a settled payment (a toll quote, or a base payment settled before a browser render), any error carries details.callerCharged, details.ledgerId, details.ledgerStatus (needs_refund, toll_committed or charged_no_content), details.refundableUsd and details.committedTollUsd, details.upstreamTransaction, details.ledgerPersisted and the headers X-Url2md-Caller-Charged / X-Url2md-Ledger-Id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Temporarily unavailable (ROBOTS_UNREACHABLE, RENDER_UNAVAILABLE, PAYMENT_UNAVAILABLE, TOLL_FUNDING_UNAVAILABLE). TOLL_FUNDING_UNAVAILABLE means the page charges an x402 toll that url2md's hot wallet cannot fund right now, counting the authorizations it has already signed; it is refused before any payment settles, so nothing was charged, and details.retryAfterSeconds says when to try again.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"504":{"description":"Upstream timed out (UPSTREAM_TIMEOUT): the fetch — robots.txt, every redirect hop and the body on one deadline — did not finish within 20 seconds. A browser render is bounded separately at 55 seconds (30 s navigation plus 25 s action and slack) and fails as RENDER_FAILED, so one call is bounded at 75 seconds; set your own timeout to 90 seconds. Giving up earlier does not cancel the call, and on a page that needed a browser the payment has already settled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"statsToken":{"type":"http","scheme":"bearer","description":"The STATS_TOKEN secret. Operator only; no paid route uses it."},"statsBasic":{"type":"http","scheme":"basic","description":"The STATS_TOKEN secret as the password (any username), so a browser prompts for it instead of it travelling in a URL."},"mcpOAuth":{"type":"oauth2","description":"OAuth 2.1 for the hosted MCP connector only. Discovery: /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource/mcp. Clients register at /register or carry a Client ID Metadata Document; PKCE S256 is required of every client; the user signs in with GitHub on the consent page. No paid route uses it: x402 is the only paid-access mechanism.","flows":{"authorizationCode":{"authorizationUrl":"https://url2md.io/authorize","tokenUrl":"https://url2md.io/token","refreshUrl":"https://url2md.io/token","scopes":{"url2md:fetch":"Call the connector's one tool, fetch_markdown, within the signed-in user's allowance"}}}}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message","retryable"],"properties":{"code":{"type":"string","enum":["INVALID_REQUEST","METHOD_NOT_ALLOWED","NOT_FOUND","INVALID_URL","UNSUPPORTED_SCHEME","BLOCKED_HOST","ROBOTS_DISALLOWED","ROBOTS_UNREACHABLE","UPSTREAM_TIMEOUT","UPSTREAM_ERROR","UPSTREAM_STATUS","UPSTREAM_PAYMENT_REQUIRED","UPSTREAM_TOLL_TOO_HIGH","UPSTREAM_PAYMENT_FAILED","TOLL_FUNDING_UNAVAILABLE","TOO_MANY_REDIRECTS","UNSUPPORTED_CONTENT_TYPE","CONTENT_TOO_LARGE","EMPTY_CONTENT","RENDER_FAILED","RENDER_UNAVAILABLE","PDF_CONVERSION_FAILED","RATE_LIMITED","PAYMENT_UNAVAILABLE","PAYMENT_REJECTED","UNAUTHORIZED","STATS_DISABLED","ALLOWANCE_EXHAUSTED","ALLOWANCE_UNAVAILABLE","INTERNAL"]},"message":{"type":"string"},"retryable":{"type":"boolean"},"details":{"type":"object","additionalProperties":true}}}}},"Health":{"type":"object","required":["ok","service","version","checkedAt","checks"],"properties":{"ok":{"type":"boolean","description":"True only when every check is ok. An unconfirmed check is not ok."},"service":{"type":"string"},"version":{"type":"string"},"checkedAt":{"type":"string","format":"date-time"},"checks":{"type":"object","description":"One entry per dependency. Keys: config, ledgerKv, passthrough, facilitator, browser, ai, and for the hosted MCP connector oauthKv, github, oauthState, mcpLimits, testLogin (each ok and saying so where the connector is off).","additionalProperties":{"type":"object","required":["status","detail"],"properties":{"status":{"type":"string","enum":["ok","fail","unknown"]},"detail":{"type":"string","description":"One sentence saying what was checked and what came back. Never a configured value."}}}}}},"ConvertResult":{"type":"object","required":["url","finalUrl","source","markdown","words","tokensEstimate","fetchedAt","timing","render","warnings"],"properties":{"url":{"type":"string","description":"the URL as asked for"},"finalUrl":{"type":"string","description":"the URL after redirects; also sent as X-Url2md-Final-Url"},"source":{"type":"string","enum":["static","browser","pdf","origin-markdown","text"],"description":"how the markdown was produced; also sent as X-Url2md-Source"},"title":{"type":["string","null"],"description":"also sent percent-encoded as X-Url2md-Title"},"description":{"type":["string","null"]},"canonical":{"type":["string","null"]},"lang":{"type":["string","null"]},"contentType":{"type":["string","null"]},"charset":{"type":["string","null"]},"markdown":{"type":"string","description":"the converted page"},"words":{"type":"integer","description":"also sent as X-Url2md-Words"},"tokensEstimate":{"type":"integer","description":"also sent as X-Url2md-Tokens-Estimate"},"redirects":{"type":"array","items":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"status":{"type":"integer"}}}},"contentSignal":{"type":["string","null"],"description":"Upstream Content-Signal header, passed through untouched"},"fetchedAt":{"type":"string","format":"date-time"},"timing":{"type":"object","properties":{"totalMs":{"type":"integer"},"fetchMs":{"type":"integer"},"renderMs":{"type":["integer","null"]},"browserMsUsed":{"type":["integer","null"]},"convertMs":{"type":"integer"}}},"render":{"type":"object","properties":{"mode":{"type":"string","enum":["auto","static","browser"]},"escalated":{"type":"boolean"},"reasons":{"type":"array","items":{"type":"string"}}}},"warnings":{"type":"array","items":{"type":"string"}},"blockSuspected":{"type":["object","null"],"description":"set when what came back looks like a bot challenge or block page rather than the page: the origin answered 200 and the call was charged, but the content is probably not what was asked for. Also sent as X-Url2md-Block-Suspected, and named in warnings[].","properties":{"markers":{"type":"array","items":{"type":"string"}}}}}}}}}