import { CRAWL_FORMATS, CRAWL_STATUSES, ERROR_CODES, ERROR_HTTP_STATUS, ERROR_MESSAGES, HTTP_METHODS, NETWORK_CLASSES, OUTPUT_FORMATS, DEVICES, PLAN_LIMITS } from "@fetcha/core"; /** * OpenAPI 3.1 description of the public Fetcha API, derived by hand from `fetchRequestSchema`, * `sessionCreateSchema`, `crawlCreateSchema`, `mapCreateSchema`, the error catalogue and the route * handlers. Served at /docs/openapi.json. */ const BASE_URL = "https://www.fetcha.co"; const errorSchema = { type: "object", required: ["error"], properties: { error: { type: "object", required: ["code", "message", "request_id"], properties: { code: { type: "string", enum: [...ERROR_CODES], description: "Stable machine-readable error code." }, message: { type: "string", description: "Human-readable explanation. May be more specific than the default message." }, request_id: { type: ["string", "null"], description: "Request identifier, also sent as X-Fetcha-Request-ID." }, details: { type: "object", additionalProperties: true, description: "Optional context: `issues` (INVALID_REQUEST), `retry_after_ms` (RATE_LIMITED), `limit` (CONCURRENCY_LIMIT), `limit`/`used` or `limit_usd`/`spent_usd` (USAGE_LIMIT_REACHED).", }, }, }, }, } as const; function errorResponse(codes: string[]) { return { description: `Error. Possible codes: ${codes.join(", ")}.`, headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, }; } const byStatus = (codes: string[]) => { const out: Record = {}; const groups = new Map(); for (const c of codes) { const s = ERROR_HTTP_STATUS[c as keyof typeof ERROR_HTTP_STATUS]; groups.set(s, [...(groups.get(s) ?? []), c]); } for (const [status, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) out[String(status)] = errorResponse(list); return out; }; const AUTH_ERRORS = ["INVALID_API_KEY", "EMAIL_NOT_VERIFIED", "FORBIDDEN"]; const geoProps = { country: { type: "string", minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 country code of the exit IP (case-insensitive, normalised to upper case).", example: "CA" }, region: { type: "string", maxLength: 64, description: "State or province. US states and Canadian provinces accept two-letter codes or names; other values are slugified.", example: "QC" }, city: { type: "string", maxLength: 128, description: "City name, slugified.", example: "Quebec" }, } as const; const fetchRequest = { type: "object", additionalProperties: false, required: ["url"], properties: { url: { type: "string", minLength: 1, maxLength: 8192, format: "uri", description: "Absolute http(s) URL. Private, internal and non-http targets are refused (URL_NOT_ALLOWED)." }, method: { type: "string", enum: [...HTTP_METHODS], default: "GET" }, headers: { type: "object", additionalProperties: { type: "string", maxLength: 8192 }, maxProperties: 64, description: "Headers forwarded to the target; override Fetcha defaults." }, cookies: { type: "object", additionalProperties: { type: "string", maxLength: 4096 }, description: "Cookies serialised into the Cookie header." }, body: { oneOf: [{ type: "string", maxLength: 2_000_000 }, { type: "object", additionalProperties: true }], description: "Request body. Objects are JSON-serialised. Content-Type defaults to application/json. Ignored for GET/HEAD." }, timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000, description: "Overall deadline in ms for all attempts, including any browser render (max 120 s)." }, ...geoProps, network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto", description: "Network class. Only `residential` is live; `auto` resolves to it. Other explicit classes return NETWORK_UNAVAILABLE." }, session: { type: "string", maxLength: 64, description: "Session id (sess_…) from POST /v1/sessions." }, browser: { type: "boolean", default: false, description: "Render the page in the managed headless browser routed through the same network, country and session." }, browser_fallback: { type: "boolean", default: true, description: "When an HTTP attempt is blocked by a JavaScript challenge / anti-bot page, automatically retry in the browser." }, javascript: { type: "boolean", default: true, description: "Browser: set false to render with scripting disabled." }, wait_for: { type: "string", maxLength: 512, description: "Browser: CSS selector that must be present before capture." }, wait_ms: { type: "integer", minimum: 0, maximum: 30_000, description: "Browser: extra settle time in ms after the wait condition." }, wait_until: { type: "string", enum: ["load", "domcontentloaded", "networkidle"], default: "domcontentloaded", description: "Browser: navigation event to wait for." }, block_resources: { type: "boolean", default: true, description: "Browser: skip images, fonts and media." }, screenshot: { type: "boolean", default: false, description: "Browser: return a PNG screenshot (base64) in `screenshot`." }, links: { type: "boolean", default: false, description: "Return every hyperlink of the page in `links[]` (absolute URLs)." }, referer: { oneOf: [{ type: "string", enum: ["auto", "none"] }, { type: "string", format: "uri", maxLength: 2048 }], default: "auto", description: "Referer strategy: auto (none first, search-engine referer on retries), none, or a literal URL." }, device: { type: "string", enum: [...DEVICES], description: "`mobile` sets an iPhone User-Agent (and viewport in the browser) unless one is provided. `tablet` currently has no effect." }, locale: { type: "string", maxLength: 16, description: "Sets the Accept-Language header.", example: "fr-CA" }, format: { type: "string", enum: [...OUTPUT_FORMATS], default: "html", description: "html/raw: body in `content`; text: readable text in `text` (content null); markdown: Markdown in `markdown` (content null); json: body in `content` and parsed value in `json`." }, follow_redirects: { type: "boolean", default: true }, max_redirects: { type: "integer", minimum: 0, maximum: 20, default: 10 }, max_response_bytes: { type: "integer", minimum: 1024, maximum: 50_000_000, description: "Lower the response size cap for this request. Effective cap is min(value, 20 MB)." }, cache: { type: "object", additionalProperties: false, properties: { enabled: { type: "boolean", default: false }, ttl: { type: "integer", minimum: 1, maximum: 86_400, default: 300 } }, description: "Reserved. Accepted, ignored; metadata.cached is always false.", }, retries: { type: "integer", minimum: 0, maximum: 5, default: 5, description: "Additional attempts after the first (max 5)." }, debug: { type: "boolean", default: false, description: "Include metadata.debug.attempts." }, }, } as const; const pageMetadata = { type: ["object", "null"], required: ["title", "description", "canonical", "lang", "og", "links_count"], properties: { title: { type: ["string", "null"] }, description: { type: ["string", "null"] }, canonical: { type: ["string", "null"] }, lang: { type: ["string", "null"] }, og: { type: "object", additionalProperties: { type: "string" }, description: "Open Graph properties." }, links_count: { type: "integer" }, }, description: "Parsed page metadata (HTML responses only; null otherwise).", } as const; const pageLink = { type: "object", required: ["url", "text", "internal", "nofollow"], properties: { url: { type: "string", format: "uri" }, text: { type: "string" }, internal: { type: "boolean", description: "Same registrable domain as the page." }, nofollow: { type: "boolean" }, }, } as const; const timing = { type: "object", required: ["dns_ms", "proxy_connect_ms", "tls_ms", "origin_ms", "processing_ms", "total_ms"], properties: { dns_ms: { type: "integer", description: "Hostname resolution for URL policy validation." }, proxy_connect_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." }, tls_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." }, origin_ms: { type: "integer", description: "Time to first byte on the final attempt, including connection and upstream routing." }, processing_ms: { type: "integer", description: "Decompression and format conversion." }, total_ms: { type: "integer", description: "Whole request, all attempts." }, }, } as const; const fetchResponse = { type: "object", required: ["request_id", "success", "status", "url", "final_url", "content", "content_type", "headers", "cookies", "metadata"], properties: { request_id: { type: "string", example: "req_k3j9d0f2a8b1c7e4" }, success: { type: "boolean", description: "true for a 2xx/3xx final response that was not classified as a block page." }, status: { type: "integer", description: "HTTP status returned by the target on the final attempt." }, url: { type: "string" }, final_url: { type: "string", description: "URL after redirects." }, content: { type: ["string", "null"], description: "Body for html/raw/json formats (null for text). Binary content types are base64-encoded." }, content_type: { type: ["string", "null"] }, headers: { type: "object", additionalProperties: { type: "string" }, description: "Target response headers, lower-cased names, content-encoding removed." }, cookies: { type: "array", items: { type: "object", required: ["name", "value"], properties: { name: { type: "string" }, value: { type: "string" }, domain: { type: "string" }, path: { type: "string" } } }, }, text: { type: ["string", "null"], description: "Only for format=text." }, markdown: { type: ["string", "null"], description: "Only for format=markdown. Main content first, boilerplate removed." }, json: { description: "Only for format=json when the body parsed as JSON." }, page: { $ref: "#/components/schemas/PageMetadata" }, links: { type: "array", items: { $ref: "#/components/schemas/PageLink" }, description: "Only with links=true on an HTML response." }, screenshot: { type: "string", description: "Only with browser rendering and screenshot=true. PNG, base64." }, metadata: { type: "object", required: ["network", "country", "mode", "attempts", "duration_ms", "bytes", "session", "cached"], properties: { network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] }, country: { type: ["string", "null"] }, mode: { type: "string", enum: ["http", "browser"], description: "How the final attempt was made." }, attempts: { type: "integer", minimum: 1 }, duration_ms: { type: "integer" }, bytes: { type: "integer", description: "Bytes transferred across all attempts." }, session: { type: ["string", "null"] }, cached: { type: "boolean", description: "Always false today." }, timing: { $ref: "#/components/schemas/Timing" }, debug: { type: "object", properties: { attempts: { type: "array", items: { type: "object", properties: { provider: { type: "string", description: "Neutral route alias (network-a, network-b, …)." }, network: { type: "string" }, mode: { type: "string", enum: ["http", "browser"] }, country: { type: ["string", "null"] }, outcome: { type: "string", enum: ["success", "blocked", "timeout", "error", "provider_error", "too_large"] }, block_reason: { type: ["string", "null"], description: "Detector that classified a blocked attempt (cloudflare_challenge, datadome, captcha, soft_block, …)." }, status: { type: ["integer", "null"] }, duration_ms: { type: "integer" }, }, }, }, }, }, }, }, }, } as const; const sessionCreate = { type: "object", additionalProperties: false, properties: { ...geoProps, network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" }, ttl: { type: "integer", minimum: 60, maximum: 1800, default: 600, description: "Lifetime in seconds, fixed at creation." }, label: { type: "string", maxLength: 128 }, }, } as const; const session = { type: "object", required: ["id", "label", "status", "network", "country", "region", "city", "request_count", "last_used_at", "expires_at", "created_at"], properties: { id: { type: "string", example: "sess_8f2k1m9d3p7q4r6s" }, label: { type: ["string", "null"] }, status: { type: "string", enum: ["active", "expired", "closed"] }, network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] }, country: { type: ["string", "null"] }, region: { type: ["string", "null"], description: "Normalised slug." }, city: { type: ["string", "null"], description: "Normalised slug." }, request_count: { type: "integer" }, last_used_at: { type: ["string", "null"], format: "date-time" }, expires_at: { type: "string", format: "date-time" }, created_at: { type: "string", format: "date-time" }, }, } as const; const crawlCreate = { type: "object", additionalProperties: false, required: ["url"], properties: { url: { type: "string", minLength: 1, maxLength: 8192, format: "uri", description: "Seed URL (same URL policy as fetch)." }, max_pages: { type: "integer", minimum: 1, maximum: 5000, default: 25, description: "Maximum pages to fetch (capped at 2,000)." }, max_depth: { type: "integer", minimum: 0, maximum: 10, default: 2 }, same_domain: { type: "boolean", default: true }, allow_subdomains: { type: "boolean", default: false }, include_patterns: { type: "array", maxItems: 50, items: { type: "string", maxLength: 512 }, description: "Glob with * or /regex/." }, exclude_patterns: { type: "array", maxItems: 50, items: { type: "string", maxLength: 512 } }, respect_robots: { type: "boolean", default: true }, use_sitemap: { type: "boolean", default: false }, concurrency: { type: "integer", minimum: 1, maximum: 10, default: 3 }, delay_ms: { type: "integer", minimum: 0, maximum: 30_000, default: 0 }, timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000, description: "Per-page timeout." }, format: { type: "string", enum: [...CRAWL_FORMATS], default: "markdown" }, main_content: { type: "boolean", default: true }, country: geoProps.country, network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" }, browser: { type: "boolean", default: false }, browser_fallback: { type: "boolean", default: true }, headers: { type: "object", additionalProperties: { type: "string", maxLength: 8192 }, maxProperties: 64 }, webhook_url: { type: "string", format: "uri", maxLength: 2048, description: "Called once when the job finishes (delivery not yet active)." }, label: { type: "string", maxLength: 128 }, }, } as const; const crawlStats = { type: "object", required: ["discovered", "fetched", "ok", "blocked", "failed", "bytes"], properties: { discovered: { type: "integer" }, fetched: { type: "integer" }, ok: { type: "integer" }, blocked: { type: "integer" }, failed: { type: "integer" }, bytes: { type: "integer" }, }, } as const; const crawlJob = { type: "object", required: ["id", "status", "label", "seed_url", "domain", "options", "stats", "error", "created_at", "started_at", "completed_at"], properties: { id: { type: "string", example: "crawl_3k9d0f2a8b1c7e4m" }, status: { type: "string", enum: [...CRAWL_STATUSES] }, label: { type: ["string", "null"] }, seed_url: { type: "string" }, domain: { type: "string" }, options: { $ref: "#/components/schemas/CrawlCreate" }, stats: { $ref: "#/components/schemas/CrawlStats" }, error: { type: ["object", "null"], properties: { code: { type: "string" }, message: { type: "string" } } }, created_at: { type: "string", format: "date-time" }, started_at: { type: ["string", "null"], format: "date-time" }, completed_at: { type: ["string", "null"], format: "date-time" }, }, } as const; const crawlCreated = { type: "object", required: ["id", "status", "seed_url", "created_at", "options"], properties: { id: { type: "string", example: "crawl_3k9d0f2a8b1c7e4m" }, status: { type: "string", const: "queued" }, seed_url: { type: "string" }, created_at: { type: "string", format: "date-time" }, options: { $ref: "#/components/schemas/CrawlCreate" }, }, } as const; const crawlPage = { type: "object", required: ["id", "url", "final_url", "depth", "status", "http_status", "error_code", "title", "description", "content_type", "content", "links_count", "bytes", "duration_ms", "mode", "fetched_at"], properties: { id: { type: "string" }, url: { type: "string" }, final_url: { type: ["string", "null"] }, depth: { type: "integer" }, status: { type: "string", enum: ["success", "blocked", "failed"] }, http_status: { type: ["integer", "null"] }, error_code: { type: ["string", "null"] }, title: { type: ["string", "null"] }, description: { type: ["string", "null"] }, content_type: { type: ["string", "null"] }, content: { type: ["string", "null"], description: "Page content in the job's format." }, links_count: { type: ["integer", "null"] }, bytes: { type: ["integer", "null"] }, duration_ms: { type: ["integer", "null"] }, mode: { type: ["string", "null"], enum: ["http", "browser", null] }, fetched_at: { type: ["string", "null"], format: "date-time" }, }, } as const; const mapCreate = { type: "object", additionalProperties: false, required: ["url"], properties: { url: { type: "string", minLength: 1, maxLength: 8192, format: "uri" }, limit: { type: "integer", minimum: 1, maximum: 10_000, default: 1000 }, use_sitemap: { type: "boolean", default: true }, use_links: { type: "boolean", default: true }, same_domain: { type: "boolean", default: true }, allow_subdomains: { type: "boolean", default: false }, search: { type: "string", maxLength: 256, description: "Substring, glob (*) or /regex/ filter." }, country: geoProps.country, network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" }, timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000 }, }, } as const; const mapResult = { type: "object", required: ["url", "count", "urls", "sources", "truncated"], properties: { url: { type: "string" }, count: { type: "integer" }, urls: { type: "array", items: { type: "string", format: "uri" } }, sources: { type: "object", required: ["sitemap", "links"], properties: { sitemap: { type: "integer" }, links: { type: "integer" } } }, truncated: { type: "boolean" }, }, } as const; const CRAWL_ERRORS = [...AUTH_ERRORS, "INVALID_REQUEST", "URL_NOT_ALLOWED", "CRAWL_LIMIT_REACHED", "RATE_LIMITED", "INTERNAL_ERROR"]; const plans = Object.values(PLAN_LIMITS).map((p) => `${p.label}: ${p.concurrency} concurrent, ${p.monthly_requests >= Number.MAX_SAFE_INTEGER ? "unlimited" : p.monthly_requests.toLocaleString("en-US")} requests/month, ${p.max_timeout_ms / 1000} s max timeout, ${p.max_retries} max retries, ${p.browser_concurrency} concurrent browser renders, ${p.crawl_max_pages.toLocaleString("en-US")} pages per crawl job, ${p.crawl_concurrent_jobs} concurrent crawl jobs`); const document = { openapi: "3.1.0", info: { title: "Fetcha API", version: "0.2.0", summary: "Intelligent Web Access Infrastructure", description: [ "One API to fetch, render and crawl web pages through Fetcha's routing engine. Authenticate with `Authorization: Bearer fch_live_…` (or `X-API-Key`).", "", "Every response carries `X-Fetcha-Request-ID`. Errors use a single envelope `{ error: { code, message, request_id, details? } }`.", "", "Private platform (invitation-only). Plan: " + plans.join("; ") + ".", "", "Not yet available: browser actions (`POST /v1/browser`), structured extraction (`POST /v1/extract`), webhook delivery, datacenter/isp/mobile network classes.", ].join("\n"), contact: { name: "Fetcha support", email: "support@fetcha.co", url: `${BASE_URL}/docs` }, termsOfService: `${BASE_URL}/legal/terms`, }, servers: [{ url: BASE_URL }], externalDocs: { url: `${BASE_URL}/docs`, description: "Fetcha documentation" }, tags: [ { name: "Fetch", description: "Retrieve URLs through the routing engine." }, { name: "Crawl", description: "Asynchronous site crawls and synchronous URL discovery." }, { name: "Sessions", description: "Sticky exit identities." }, { name: "Account", description: "Key introspection and usage." }, { name: "Health", description: "Public health probes (no authentication)." }, ], security: [{ bearerAuth: [] }, { apiKeyHeader: [] }], paths: { "/v1/fetch": { post: { tags: ["Fetch"], operationId: "fetch", summary: "Fetch a URL", description: "Fetches the URL through the best available route, retrying and escalating on blocks (up to a managed browser render for JavaScript challenges) within the request's single `timeout`. Returns 200 whenever the target answered, including 4xx/5xx and fully blocked targets (`success: false`). Requires scope `fetch:execute`.", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/FetchRequest" }, example: { url: "https://example.com", country: "CA", format: "markdown" } } } }, responses: { "200": { description: "Fetcha obtained a response from the target. Inspect `success`, `status` and `metadata.mode`.", headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/FetchResponse" } } }, }, ...byStatus([ ...AUTH_ERRORS, "INVALID_REQUEST", "URL_NOT_ALLOWED", "NETWORK_UNAVAILABLE", "BROWSER_UNAVAILABLE", "USAGE_LIMIT_REACHED", "SESSION_NOT_FOUND", "SESSION_EXPIRED", "RATE_LIMITED", "CONCURRENCY_LIMIT", "INTERNAL_ERROR", "TARGET_UNAVAILABLE", "RESPONSE_TOO_LARGE", "TOO_MANY_REDIRECTS", "PROVIDER_UNAVAILABLE", "TARGET_TIMEOUT", "BROWSER_TIMEOUT", ]), }, }, }, "/v1/crawl": { post: { tags: ["Crawl"], operationId: "createCrawl", summary: "Start a crawl job", description: "Queues an asynchronous crawl from a seed URL. Each crawled page is a normal fetch request (source `crawl`). Requires scope `fetch:execute`. Limits: 2,000 pages per job, 5 concurrent jobs per organization.", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlCreate" }, example: { url: "https://docs.example.com/", max_pages: 200, max_depth: 3, include_patterns: ["/docs/*"], format: "markdown" } } } }, responses: { "202": { description: "Job queued.", headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlCreated" } } } }, ...byStatus(CRAWL_ERRORS), }, }, get: { tags: ["Crawl"], operationId: "listCrawls", summary: "List crawl jobs", description: "Most recent jobs of the project, newest first.", parameters: [{ name: "limit", in: "query", required: false, schema: { type: "integer", default: 50 } }], responses: { "200": { description: "Jobs.", content: { "application/json": { schema: { type: "object", required: ["data"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/CrawlJob" } } } } } } }, ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]), }, }, }, "/v1/crawl/{id}": { parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" }, example: "crawl_3k9d0f2a8b1c7e4m" }], get: { tags: ["Crawl"], operationId: "getCrawl", summary: "Get a crawl job", responses: { "200": { description: "Job with live status and stats.", content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlJob" } } } }, ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]), }, }, delete: { tags: ["Crawl"], operationId: "cancelCrawl", summary: "Cancel a crawl job", description: "Stops a queued or running job. Pages already fetched remain available. Requires scope `fetch:execute`.", responses: { "200": { description: "Cancelled.", content: { "application/json": { schema: { type: "object", required: ["id", "status"], properties: { id: { type: "string" }, status: { type: "string", const: "cancelled" } } } } } }, ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]), }, }, }, "/v1/crawl/{id}/pages": { get: { tags: ["Crawl"], operationId: "listCrawlPages", summary: "List crawled pages", description: "Pages of a job, ordered by fetch time, with cursor pagination.", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" } }, { name: "cursor", in: "query", required: false, schema: { type: "string" }, description: "Opaque cursor from `next_cursor`." }, { name: "limit", in: "query", required: false, schema: { type: "integer", default: 100 } }, { name: "status", in: "query", required: false, schema: { type: "string", enum: ["success", "blocked", "failed"] } }, ], responses: { "200": { description: "A page of results.", content: { "application/json": { schema: { type: "object", required: ["data", "next_cursor"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/CrawlPage" } }, next_cursor: { type: ["string", "null"] } } } } }, }, ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]), }, }, }, "/v1/map": { post: { tags: ["Crawl"], operationId: "mapSite", summary: "Map a site's URLs", description: "Synchronously lists the URLs of a site from its sitemap(s) and the links of the seed page (up to 60 s). Requires scope `fetch:execute`.", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MapCreate" }, example: { url: "https://docs.example.com/", search: "/docs/*", limit: 500 } } } }, responses: { "200": { description: "URL list.", headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/MapResult" } } } }, ...byStatus([...AUTH_ERRORS, "INVALID_REQUEST", "URL_NOT_ALLOWED", "RATE_LIMITED", "TARGET_TIMEOUT", "TARGET_UNAVAILABLE", "INTERNAL_ERROR"]), }, }, }, "/v1/sessions": { post: { tags: ["Sessions"], operationId: "createSession", summary: "Create a session", description: "Creates a sticky session pinned to one route. Supports the `Idempotency-Key` header (24 h, per project). Requires scope `sessions:write`.", parameters: [{ name: "Idempotency-Key", in: "header", required: false, schema: { type: "string" }, description: "Return the session previously created with the same key instead of creating a new one." }], requestBody: { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/SessionCreate" }, example: { country: "CA", region: "QC", ttl: 900, label: "checkout-user-42" } } } }, responses: { "200": { description: "Session created (or existing session returned for a known Idempotency-Key).", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } }, ...byStatus([...AUTH_ERRORS, "INVALID_REQUEST", "NETWORK_UNAVAILABLE", "INTERNAL_ERROR"]), }, }, get: { tags: ["Sessions"], operationId: "listSessions", summary: "List sessions", description: "The 100 most recent sessions of the project, newest first, including expired and closed ones.", responses: { "200": { description: "Sessions.", content: { "application/json": { schema: { type: "object", required: ["data"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/Session" } } } } } } }, ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]), }, }, }, "/v1/sessions/{id}": { parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" }, example: "sess_8f2k1m9d3p7q4r6s" }], get: { tags: ["Sessions"], operationId: "getSession", summary: "Get a session", responses: { "200": { description: "Session.", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } }, ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]), }, }, delete: { tags: ["Sessions"], operationId: "closeSession", summary: "Close a session", description: "Closes the session immediately. Requires scope `sessions:write`.", responses: { "200": { description: "Closed.", content: { "application/json": { schema: { type: "object", required: ["id", "status"], properties: { id: { type: "string" }, status: { type: "string", const: "closed" } } } } } }, ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]), }, }, }, "/v1/me": { get: { tags: ["Account"], operationId: "me", summary: "Introspect the API key", description: "Validates the key and returns the project, organization and key metadata. No scope required.", responses: { "200": { description: "Principal.", content: { "application/json": { schema: { type: "object", required: ["project", "organization", "key"], properties: { project: { type: "object", properties: { id: { type: "string" }, name: { type: "string" } } }, organization: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, plan: { type: "string", enum: Object.keys(PLAN_LIMITS) } } }, key: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, mode: { type: "string", enum: ["live", "test"] }, scopes: { type: "array", items: { type: "string", enum: ["fetch:execute", "browser:use", "crawl:execute", "sessions:write", "usage:read"] } }, }, }, }, }, }, }, }, ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]), }, }, }, "/v1/usage": { get: { tags: ["Account"], operationId: "usage", summary: "Monthly usage", description: "Usage for the current calendar month (UTC) at organization and project level. Requires scope `usage:read`.", responses: { "200": { description: "Usage summary.", content: { "application/json": { schema: { type: "object", required: ["period_start", "plan", "organization", "project", "remaining_requests"], properties: { period_start: { type: "string", format: "date-time" }, plan: { type: "object", properties: { id: { type: "string" }, label: { type: "string" }, monthly_requests: { type: "integer" }, concurrency: { type: "integer" } } }, organization: { type: "object", properties: { requests: { type: "number" }, spend_usd: { type: "number" } } }, project: { type: "object", properties: { requests: { type: "number" }, spend_usd: { type: "number" }, successful_requests: { type: "integer" }, success_rate: { type: ["number", "null"], description: "Percentage with one decimal; null without requests." }, bandwidth_bytes: { type: "number" }, latency_p50_ms: { type: "integer" }, latency_p95_ms: { type: "integer" }, }, }, remaining_requests: { type: "integer" }, }, }, }, }, }, ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]), }, }, }, "/api/health": { get: { tags: ["Health"], operationId: "health", summary: "Liveness", security: [], responses: { "200": { description: "OK.", content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", const: "ok" }, version: { type: "string" }, time: { type: "string", format: "date-time" } } } } } } }, }, }, "/api/ready": { get: { tags: ["Health"], operationId: "ready", summary: "Readiness", security: [], responses: { "200": { description: "Ready.", content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", enum: ["ready", "degraded"] }, checks: { type: "object", properties: { database: { type: "boolean" }, cache: { type: "boolean" }, providers: { type: "boolean" } } }, available_networks: { type: "array", items: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] } }, }, }, }, }, }, "503": { description: "Degraded (same body with status `degraded`)." }, }, }, }, }, components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", description: "`Authorization: Bearer fch_live_…` or `fch_test_…`." }, apiKeyHeader: { type: "apiKey", in: "header", name: "X-API-Key", description: "Alternative to the Authorization header. Takes precedence when both are present." }, }, headers: { "X-Fetcha-Request-ID": { schema: { type: "string" }, description: "Request identifier (req_…). Quote it to support." }, }, schemas: { Error: errorSchema, FetchRequest: fetchRequest, FetchResponse: fetchResponse, Timing: timing, PageMetadata: pageMetadata, PageLink: pageLink, SessionCreate: sessionCreate, Session: session, CrawlCreate: crawlCreate, CrawlCreated: crawlCreated, CrawlJob: crawlJob, CrawlStats: crawlStats, CrawlPage: crawlPage, MapCreate: mapCreate, MapResult: mapResult, }, }, "x-error-catalogue": Object.fromEntries(ERROR_CODES.map((c) => [c, { status: ERROR_HTTP_STATUS[c], message: ERROR_MESSAGES[c] }])), }; export const dynamic = "force-static"; export function GET() { return new Response(JSON.stringify(document, null, 2), { status: 200, headers: { "content-type": "application/json; charset=utf-8", "content-disposition": 'inline; filename="fetcha-openapi.json"', "cache-control": "public, max-age=3600", }, }); }