TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import { CRAWL_FORMATS, CRAWL_STATUSES, ERROR_CODES, ERROR_HTTP_STATUS, ERROR_MESSAGES, HTTP_METHODS, NETWORK_CLASSES, OUTPUT_FORMATS, DEVICES, PLAN_LIMITS } from "@fetcha/core";23/**4 * OpenAPI 3.1 description of the public Fetcha API, derived by hand from `fetchRequestSchema`,5 * `sessionCreateSchema`, `crawlCreateSchema`, `mapCreateSchema`, the error catalogue and the route6 * handlers. Served at /docs/openapi.json.7 */89const BASE_URL = "https://www.fetcha.co";1011const errorSchema = {12 type: "object",13 required: ["error"],14 properties: {15 error: {16 type: "object",17 required: ["code", "message", "request_id"],18 properties: {19 code: { type: "string", enum: [...ERROR_CODES], description: "Stable machine-readable error code." },20 message: { type: "string", description: "Human-readable explanation. May be more specific than the default message." },21 request_id: { type: ["string", "null"], description: "Request identifier, also sent as X-Fetcha-Request-ID." },22 details: {23 type: "object",24 additionalProperties: true,25 description: "Optional context: `issues` (INVALID_REQUEST), `retry_after_ms` (RATE_LIMITED), `limit` (CONCURRENCY_LIMIT), `limit`/`used` or `limit_usd`/`spent_usd` (USAGE_LIMIT_REACHED).",26 },27 },28 },29 },30} as const;3132function errorResponse(codes: string[]) {33 return {34 description: `Error. Possible codes: ${codes.join(", ")}.`,35 headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } },36 content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },37 };38}3940const byStatus = (codes: string[]) => {41 const out: Record<string, unknown> = {};42 const groups = new Map<number, string[]>();43 for (const c of codes) {44 const s = ERROR_HTTP_STATUS[c as keyof typeof ERROR_HTTP_STATUS];45 groups.set(s, [...(groups.get(s) ?? []), c]);46 }47 for (const [status, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) out[String(status)] = errorResponse(list);48 return out;49};5051const AUTH_ERRORS = ["INVALID_API_KEY", "EMAIL_NOT_VERIFIED", "FORBIDDEN"];5253const geoProps = {54 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" },55 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" },56 city: { type: "string", maxLength: 128, description: "City name, slugified.", example: "Quebec" },57} as const;5859const fetchRequest = {60 type: "object",61 additionalProperties: false,62 required: ["url"],63 properties: {64 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)." },65 method: { type: "string", enum: [...HTTP_METHODS], default: "GET" },66 headers: { type: "object", additionalProperties: { type: "string", maxLength: 8192 }, maxProperties: 64, description: "Headers forwarded to the target; override Fetcha defaults." },67 cookies: { type: "object", additionalProperties: { type: "string", maxLength: 4096 }, description: "Cookies serialised into the Cookie header." },68 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." },69 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)." },70 ...geoProps,71 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." },72 session: { type: "string", maxLength: 64, description: "Session id (sess_…) from POST /v1/sessions." },73 browser: { type: "boolean", default: false, description: "Render the page in the managed headless browser routed through the same network, country and session." },74 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." },75 javascript: { type: "boolean", default: true, description: "Browser: set false to render with scripting disabled." },76 wait_for: { type: "string", maxLength: 512, description: "Browser: CSS selector that must be present before capture." },77 wait_ms: { type: "integer", minimum: 0, maximum: 30_000, description: "Browser: extra settle time in ms after the wait condition." },78 wait_until: { type: "string", enum: ["load", "domcontentloaded", "networkidle"], default: "domcontentloaded", description: "Browser: navigation event to wait for." },79 block_resources: { type: "boolean", default: true, description: "Browser: skip images, fonts and media." },80 screenshot: { type: "boolean", default: false, description: "Browser: return a PNG screenshot (base64) in `screenshot`." },81 links: { type: "boolean", default: false, description: "Return every hyperlink of the page in `links[]` (absolute URLs)." },82 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." },83 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." },84 locale: { type: "string", maxLength: 16, description: "Sets the Accept-Language header.", example: "fr-CA" },85 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`." },86 follow_redirects: { type: "boolean", default: true },87 max_redirects: { type: "integer", minimum: 0, maximum: 20, default: 10 },88 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)." },89 cache: {90 type: "object",91 additionalProperties: false,92 properties: { enabled: { type: "boolean", default: false }, ttl: { type: "integer", minimum: 1, maximum: 86_400, default: 300 } },93 description: "Reserved. Accepted, ignored; metadata.cached is always false.",94 },95 retries: { type: "integer", minimum: 0, maximum: 5, default: 5, description: "Additional attempts after the first (max 5)." },96 debug: { type: "boolean", default: false, description: "Include metadata.debug.attempts." },97 },98} as const;99100const pageMetadata = {101 type: ["object", "null"],102 required: ["title", "description", "canonical", "lang", "og", "links_count"],103 properties: {104 title: { type: ["string", "null"] },105 description: { type: ["string", "null"] },106 canonical: { type: ["string", "null"] },107 lang: { type: ["string", "null"] },108 og: { type: "object", additionalProperties: { type: "string" }, description: "Open Graph properties." },109 links_count: { type: "integer" },110 },111 description: "Parsed page metadata (HTML responses only; null otherwise).",112} as const;113114const pageLink = {115 type: "object",116 required: ["url", "text", "internal", "nofollow"],117 properties: {118 url: { type: "string", format: "uri" },119 text: { type: "string" },120 internal: { type: "boolean", description: "Same registrable domain as the page." },121 nofollow: { type: "boolean" },122 },123} as const;124125const timing = {126 type: "object",127 required: ["dns_ms", "proxy_connect_ms", "tls_ms", "origin_ms", "processing_ms", "total_ms"],128 properties: {129 dns_ms: { type: "integer", description: "Hostname resolution for URL policy validation." },130 proxy_connect_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." },131 tls_ms: { type: "integer", description: "Reported as 0 in the current build (included in origin_ms)." },132 origin_ms: { type: "integer", description: "Time to first byte on the final attempt, including connection and upstream routing." },133 processing_ms: { type: "integer", description: "Decompression and format conversion." },134 total_ms: { type: "integer", description: "Whole request, all attempts." },135 },136} as const;137138const fetchResponse = {139 type: "object",140 required: ["request_id", "success", "status", "url", "final_url", "content", "content_type", "headers", "cookies", "metadata"],141 properties: {142 request_id: { type: "string", example: "req_k3j9d0f2a8b1c7e4" },143 success: { type: "boolean", description: "true for a 2xx/3xx final response that was not classified as a block page." },144 status: { type: "integer", description: "HTTP status returned by the target on the final attempt." },145 url: { type: "string" },146 final_url: { type: "string", description: "URL after redirects." },147 content: { type: ["string", "null"], description: "Body for html/raw/json formats (null for text). Binary content types are base64-encoded." },148 content_type: { type: ["string", "null"] },149 headers: { type: "object", additionalProperties: { type: "string" }, description: "Target response headers, lower-cased names, content-encoding removed." },150 cookies: {151 type: "array",152 items: { type: "object", required: ["name", "value"], properties: { name: { type: "string" }, value: { type: "string" }, domain: { type: "string" }, path: { type: "string" } } },153 },154 text: { type: ["string", "null"], description: "Only for format=text." },155 markdown: { type: ["string", "null"], description: "Only for format=markdown. Main content first, boilerplate removed." },156 json: { description: "Only for format=json when the body parsed as JSON." },157 page: { $ref: "#/components/schemas/PageMetadata" },158 links: { type: "array", items: { $ref: "#/components/schemas/PageLink" }, description: "Only with links=true on an HTML response." },159 screenshot: { type: "string", description: "Only with browser rendering and screenshot=true. PNG, base64." },160 metadata: {161 type: "object",162 required: ["network", "country", "mode", "attempts", "duration_ms", "bytes", "session", "cached"],163 properties: {164 network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] },165 country: { type: ["string", "null"] },166 mode: { type: "string", enum: ["http", "browser"], description: "How the final attempt was made." },167 attempts: { type: "integer", minimum: 1 },168 duration_ms: { type: "integer" },169 bytes: { type: "integer", description: "Bytes transferred across all attempts." },170 session: { type: ["string", "null"] },171 cached: { type: "boolean", description: "Always false today." },172 timing: { $ref: "#/components/schemas/Timing" },173 debug: {174 type: "object",175 properties: {176 attempts: {177 type: "array",178 items: {179 type: "object",180 properties: {181 provider: { type: "string", description: "Neutral route alias (network-a, network-b, …)." },182 network: { type: "string" },183 mode: { type: "string", enum: ["http", "browser"] },184 country: { type: ["string", "null"] },185 outcome: { type: "string", enum: ["success", "blocked", "timeout", "error", "provider_error", "too_large"] },186 block_reason: { type: ["string", "null"], description: "Detector that classified a blocked attempt (cloudflare_challenge, datadome, captcha, soft_block, …)." },187 status: { type: ["integer", "null"] },188 duration_ms: { type: "integer" },189 },190 },191 },192 },193 },194 },195 },196 },197} as const;198199const sessionCreate = {200 type: "object",201 additionalProperties: false,202 properties: {203 ...geoProps,204 network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" },205 ttl: { type: "integer", minimum: 60, maximum: 1800, default: 600, description: "Lifetime in seconds, fixed at creation." },206 label: { type: "string", maxLength: 128 },207 },208} as const;209210const session = {211 type: "object",212 required: ["id", "label", "status", "network", "country", "region", "city", "request_count", "last_used_at", "expires_at", "created_at"],213 properties: {214 id: { type: "string", example: "sess_8f2k1m9d3p7q4r6s" },215 label: { type: ["string", "null"] },216 status: { type: "string", enum: ["active", "expired", "closed"] },217 network: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] },218 country: { type: ["string", "null"] },219 region: { type: ["string", "null"], description: "Normalised slug." },220 city: { type: ["string", "null"], description: "Normalised slug." },221 request_count: { type: "integer" },222 last_used_at: { type: ["string", "null"], format: "date-time" },223 expires_at: { type: "string", format: "date-time" },224 created_at: { type: "string", format: "date-time" },225 },226} as const;227228const crawlCreate = {229 type: "object",230 additionalProperties: false,231 required: ["url"],232 properties: {233 url: { type: "string", minLength: 1, maxLength: 8192, format: "uri", description: "Seed URL (same URL policy as fetch)." },234 max_pages: { type: "integer", minimum: 1, maximum: 5000, default: 25, description: "Maximum pages to fetch (capped at 2,000)." },235 max_depth: { type: "integer", minimum: 0, maximum: 10, default: 2 },236 same_domain: { type: "boolean", default: true },237 allow_subdomains: { type: "boolean", default: false },238 include_patterns: { type: "array", maxItems: 50, items: { type: "string", maxLength: 512 }, description: "Glob with * or /regex/." },239 exclude_patterns: { type: "array", maxItems: 50, items: { type: "string", maxLength: 512 } },240 respect_robots: { type: "boolean", default: true },241 use_sitemap: { type: "boolean", default: false },242 concurrency: { type: "integer", minimum: 1, maximum: 10, default: 3 },243 delay_ms: { type: "integer", minimum: 0, maximum: 30_000, default: 0 },244 timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000, description: "Per-page timeout." },245 format: { type: "string", enum: [...CRAWL_FORMATS], default: "markdown" },246 main_content: { type: "boolean", default: true },247 country: geoProps.country,248 network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" },249 browser: { type: "boolean", default: false },250 browser_fallback: { type: "boolean", default: true },251 headers: { type: "object", additionalProperties: { type: "string", maxLength: 8192 }, maxProperties: 64 },252 webhook_url: { type: "string", format: "uri", maxLength: 2048, description: "Called once when the job finishes (delivery not yet active)." },253 label: { type: "string", maxLength: 128 },254 },255} as const;256257const crawlStats = {258 type: "object",259 required: ["discovered", "fetched", "ok", "blocked", "failed", "bytes"],260 properties: {261 discovered: { type: "integer" },262 fetched: { type: "integer" },263 ok: { type: "integer" },264 blocked: { type: "integer" },265 failed: { type: "integer" },266 bytes: { type: "integer" },267 },268} as const;269270const crawlJob = {271 type: "object",272 required: ["id", "status", "label", "seed_url", "domain", "options", "stats", "error", "created_at", "started_at", "completed_at"],273 properties: {274 id: { type: "string", example: "crawl_3k9d0f2a8b1c7e4m" },275 status: { type: "string", enum: [...CRAWL_STATUSES] },276 label: { type: ["string", "null"] },277 seed_url: { type: "string" },278 domain: { type: "string" },279 options: { $ref: "#/components/schemas/CrawlCreate" },280 stats: { $ref: "#/components/schemas/CrawlStats" },281 error: { type: ["object", "null"], properties: { code: { type: "string" }, message: { type: "string" } } },282 created_at: { type: "string", format: "date-time" },283 started_at: { type: ["string", "null"], format: "date-time" },284 completed_at: { type: ["string", "null"], format: "date-time" },285 },286} as const;287288const crawlCreated = {289 type: "object",290 required: ["id", "status", "seed_url", "created_at", "options"],291 properties: {292 id: { type: "string", example: "crawl_3k9d0f2a8b1c7e4m" },293 status: { type: "string", const: "queued" },294 seed_url: { type: "string" },295 created_at: { type: "string", format: "date-time" },296 options: { $ref: "#/components/schemas/CrawlCreate" },297 },298} as const;299300const crawlPage = {301 type: "object",302 required: ["id", "url", "final_url", "depth", "status", "http_status", "error_code", "title", "description", "content_type", "content", "links_count", "bytes", "duration_ms", "mode", "fetched_at"],303 properties: {304 id: { type: "string" },305 url: { type: "string" },306 final_url: { type: ["string", "null"] },307 depth: { type: "integer" },308 status: { type: "string", enum: ["success", "blocked", "failed"] },309 http_status: { type: ["integer", "null"] },310 error_code: { type: ["string", "null"] },311 title: { type: ["string", "null"] },312 description: { type: ["string", "null"] },313 content_type: { type: ["string", "null"] },314 content: { type: ["string", "null"], description: "Page content in the job's format." },315 links_count: { type: ["integer", "null"] },316 bytes: { type: ["integer", "null"] },317 duration_ms: { type: ["integer", "null"] },318 mode: { type: ["string", "null"], enum: ["http", "browser", null] },319 fetched_at: { type: ["string", "null"], format: "date-time" },320 },321} as const;322323const mapCreate = {324 type: "object",325 additionalProperties: false,326 required: ["url"],327 properties: {328 url: { type: "string", minLength: 1, maxLength: 8192, format: "uri" },329 limit: { type: "integer", minimum: 1, maximum: 10_000, default: 1000 },330 use_sitemap: { type: "boolean", default: true },331 use_links: { type: "boolean", default: true },332 same_domain: { type: "boolean", default: true },333 allow_subdomains: { type: "boolean", default: false },334 search: { type: "string", maxLength: 256, description: "Substring, glob (*) or /regex/ filter." },335 country: geoProps.country,336 network: { type: "string", enum: [...NETWORK_CLASSES], default: "auto" },337 timeout: { type: "integer", minimum: 1000, maximum: 120_000, default: 30_000 },338 },339} as const;340341const mapResult = {342 type: "object",343 required: ["url", "count", "urls", "sources", "truncated"],344 properties: {345 url: { type: "string" },346 count: { type: "integer" },347 urls: { type: "array", items: { type: "string", format: "uri" } },348 sources: { type: "object", required: ["sitemap", "links"], properties: { sitemap: { type: "integer" }, links: { type: "integer" } } },349 truncated: { type: "boolean" },350 },351} as const;352353const CRAWL_ERRORS = [...AUTH_ERRORS, "INVALID_REQUEST", "URL_NOT_ALLOWED", "CRAWL_LIMIT_REACHED", "RATE_LIMITED", "INTERNAL_ERROR"];354355const 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`);356357const document = {358 openapi: "3.1.0",359 info: {360 title: "Fetcha API",361 version: "0.2.0",362 summary: "Intelligent Web Access Infrastructure",363 description: [364 "One API to fetch, render and crawl web pages through Fetcha's routing engine. Authenticate with `Authorization: Bearer fch_live_…` (or `X-API-Key`).",365 "",366 "Every response carries `X-Fetcha-Request-ID`. Errors use a single envelope `{ error: { code, message, request_id, details? } }`.",367 "",368 "Private platform (invitation-only). Plan: " + plans.join("; ") + ".",369 "",370 "Not yet available: browser actions (`POST /v1/browser`), structured extraction (`POST /v1/extract`), webhook delivery, datacenter/isp/mobile network classes.",371 ].join("\n"),372 contact: { name: "Fetcha support", email: "support@fetcha.co", url: `${BASE_URL}/docs` },373 termsOfService: `${BASE_URL}/legal/terms`,374 },375 servers: [{ url: BASE_URL }],376 externalDocs: { url: `${BASE_URL}/docs`, description: "Fetcha documentation" },377 tags: [378 { name: "Fetch", description: "Retrieve URLs through the routing engine." },379 { name: "Crawl", description: "Asynchronous site crawls and synchronous URL discovery." },380 { name: "Sessions", description: "Sticky exit identities." },381 { name: "Account", description: "Key introspection and usage." },382 { name: "Health", description: "Public health probes (no authentication)." },383 ],384 security: [{ bearerAuth: [] }, { apiKeyHeader: [] }],385 paths: {386 "/v1/fetch": {387 post: {388 tags: ["Fetch"],389 operationId: "fetch",390 summary: "Fetch a URL",391 description:392 "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`.",393 requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/FetchRequest" }, example: { url: "https://example.com", country: "CA", format: "markdown" } } } },394 responses: {395 "200": {396 description: "Fetcha obtained a response from the target. Inspect `success`, `status` and `metadata.mode`.",397 headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } },398 content: { "application/json": { schema: { $ref: "#/components/schemas/FetchResponse" } } },399 },400 ...byStatus([401 ...AUTH_ERRORS,402 "INVALID_REQUEST",403 "URL_NOT_ALLOWED",404 "NETWORK_UNAVAILABLE",405 "BROWSER_UNAVAILABLE",406 "USAGE_LIMIT_REACHED",407 "SESSION_NOT_FOUND",408 "SESSION_EXPIRED",409 "RATE_LIMITED",410 "CONCURRENCY_LIMIT",411 "INTERNAL_ERROR",412 "TARGET_UNAVAILABLE",413 "RESPONSE_TOO_LARGE",414 "TOO_MANY_REDIRECTS",415 "PROVIDER_UNAVAILABLE",416 "TARGET_TIMEOUT",417 "BROWSER_TIMEOUT",418 ]),419 },420 },421 },422 "/v1/crawl": {423 post: {424 tags: ["Crawl"],425 operationId: "createCrawl",426 summary: "Start a crawl job",427 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.",428 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" } } } },429 responses: {430 "202": { description: "Job queued.", headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlCreated" } } } },431 ...byStatus(CRAWL_ERRORS),432 },433 },434 get: {435 tags: ["Crawl"],436 operationId: "listCrawls",437 summary: "List crawl jobs",438 description: "Most recent jobs of the project, newest first.",439 parameters: [{ name: "limit", in: "query", required: false, schema: { type: "integer", default: 50 } }],440 responses: {441 "200": { description: "Jobs.", content: { "application/json": { schema: { type: "object", required: ["data"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/CrawlJob" } } } } } } },442 ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),443 },444 },445 },446 "/v1/crawl/{id}": {447 parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" }, example: "crawl_3k9d0f2a8b1c7e4m" }],448 get: {449 tags: ["Crawl"],450 operationId: "getCrawl",451 summary: "Get a crawl job",452 responses: {453 "200": { description: "Job with live status and stats.", content: { "application/json": { schema: { $ref: "#/components/schemas/CrawlJob" } } } },454 ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]),455 },456 },457 delete: {458 tags: ["Crawl"],459 operationId: "cancelCrawl",460 summary: "Cancel a crawl job",461 description: "Stops a queued or running job. Pages already fetched remain available. Requires scope `fetch:execute`.",462 responses: {463 "200": { description: "Cancelled.", content: { "application/json": { schema: { type: "object", required: ["id", "status"], properties: { id: { type: "string" }, status: { type: "string", const: "cancelled" } } } } } },464 ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]),465 },466 },467 },468 "/v1/crawl/{id}/pages": {469 get: {470 tags: ["Crawl"],471 operationId: "listCrawlPages",472 summary: "List crawled pages",473 description: "Pages of a job, ordered by fetch time, with cursor pagination.",474 parameters: [475 { name: "id", in: "path", required: true, schema: { type: "string" } },476 { name: "cursor", in: "query", required: false, schema: { type: "string" }, description: "Opaque cursor from `next_cursor`." },477 { name: "limit", in: "query", required: false, schema: { type: "integer", default: 100 } },478 { name: "status", in: "query", required: false, schema: { type: "string", enum: ["success", "blocked", "failed"] } },479 ],480 responses: {481 "200": {482 description: "A page of results.",483 content: { "application/json": { schema: { type: "object", required: ["data", "next_cursor"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/CrawlPage" } }, next_cursor: { type: ["string", "null"] } } } } },484 },485 ...byStatus([...AUTH_ERRORS, "CRAWL_NOT_FOUND", "INTERNAL_ERROR"]),486 },487 },488 },489 "/v1/map": {490 post: {491 tags: ["Crawl"],492 operationId: "mapSite",493 summary: "Map a site's URLs",494 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`.",495 requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/MapCreate" }, example: { url: "https://docs.example.com/", search: "/docs/*", limit: 500 } } } },496 responses: {497 "200": { description: "URL list.", headers: { "X-Fetcha-Request-ID": { $ref: "#/components/headers/X-Fetcha-Request-ID" } }, content: { "application/json": { schema: { $ref: "#/components/schemas/MapResult" } } } },498 ...byStatus([...AUTH_ERRORS, "INVALID_REQUEST", "URL_NOT_ALLOWED", "RATE_LIMITED", "TARGET_TIMEOUT", "TARGET_UNAVAILABLE", "INTERNAL_ERROR"]),499 },500 },501 },502 "/v1/sessions": {503 post: {504 tags: ["Sessions"],505 operationId: "createSession",506 summary: "Create a session",507 description: "Creates a sticky session pinned to one route. Supports the `Idempotency-Key` header (24 h, per project). Requires scope `sessions:write`.",508 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." }],509 requestBody: { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/SessionCreate" }, example: { country: "CA", region: "QC", ttl: 900, label: "checkout-user-42" } } } },510 responses: {511 "200": { description: "Session created (or existing session returned for a known Idempotency-Key).", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } },512 ...byStatus([...AUTH_ERRORS, "INVALID_REQUEST", "NETWORK_UNAVAILABLE", "INTERNAL_ERROR"]),513 },514 },515 get: {516 tags: ["Sessions"],517 operationId: "listSessions",518 summary: "List sessions",519 description: "The 100 most recent sessions of the project, newest first, including expired and closed ones.",520 responses: {521 "200": { description: "Sessions.", content: { "application/json": { schema: { type: "object", required: ["data"], properties: { data: { type: "array", items: { $ref: "#/components/schemas/Session" } } } } } } },522 ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),523 },524 },525 },526 "/v1/sessions/{id}": {527 parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" }, example: "sess_8f2k1m9d3p7q4r6s" }],528 get: {529 tags: ["Sessions"],530 operationId: "getSession",531 summary: "Get a session",532 responses: {533 "200": { description: "Session.", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } },534 ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]),535 },536 },537 delete: {538 tags: ["Sessions"],539 operationId: "closeSession",540 summary: "Close a session",541 description: "Closes the session immediately. Requires scope `sessions:write`.",542 responses: {543 "200": { description: "Closed.", content: { "application/json": { schema: { type: "object", required: ["id", "status"], properties: { id: { type: "string" }, status: { type: "string", const: "closed" } } } } } },544 ...byStatus([...AUTH_ERRORS, "SESSION_NOT_FOUND", "INTERNAL_ERROR"]),545 },546 },547 },548 "/v1/me": {549 get: {550 tags: ["Account"],551 operationId: "me",552 summary: "Introspect the API key",553 description: "Validates the key and returns the project, organization and key metadata. No scope required.",554 responses: {555 "200": {556 description: "Principal.",557 content: {558 "application/json": {559 schema: {560 type: "object",561 required: ["project", "organization", "key"],562 properties: {563 project: { type: "object", properties: { id: { type: "string" }, name: { type: "string" } } },564 organization: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, plan: { type: "string", enum: Object.keys(PLAN_LIMITS) } } },565 key: {566 type: "object",567 properties: {568 id: { type: "string" },569 name: { type: "string" },570 mode: { type: "string", enum: ["live", "test"] },571 scopes: { type: "array", items: { type: "string", enum: ["fetch:execute", "browser:use", "crawl:execute", "sessions:write", "usage:read"] } },572 },573 },574 },575 },576 },577 },578 },579 ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),580 },581 },582 },583 "/v1/usage": {584 get: {585 tags: ["Account"],586 operationId: "usage",587 summary: "Monthly usage",588 description: "Usage for the current calendar month (UTC) at organization and project level. Requires scope `usage:read`.",589 responses: {590 "200": {591 description: "Usage summary.",592 content: {593 "application/json": {594 schema: {595 type: "object",596 required: ["period_start", "plan", "organization", "project", "remaining_requests"],597 properties: {598 period_start: { type: "string", format: "date-time" },599 plan: { type: "object", properties: { id: { type: "string" }, label: { type: "string" }, monthly_requests: { type: "integer" }, concurrency: { type: "integer" } } },600 organization: { type: "object", properties: { requests: { type: "number" }, spend_usd: { type: "number" } } },601 project: {602 type: "object",603 properties: {604 requests: { type: "number" },605 spend_usd: { type: "number" },606 successful_requests: { type: "integer" },607 success_rate: { type: ["number", "null"], description: "Percentage with one decimal; null without requests." },608 bandwidth_bytes: { type: "number" },609 latency_p50_ms: { type: "integer" },610 latency_p95_ms: { type: "integer" },611 },612 },613 remaining_requests: { type: "integer" },614 },615 },616 },617 },618 },619 ...byStatus([...AUTH_ERRORS, "INTERNAL_ERROR"]),620 },621 },622 },623 "/api/health": {624 get: {625 tags: ["Health"],626 operationId: "health",627 summary: "Liveness",628 security: [],629 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" } } } } } } },630 },631 },632 "/api/ready": {633 get: {634 tags: ["Health"],635 operationId: "ready",636 summary: "Readiness",637 security: [],638 responses: {639 "200": {640 description: "Ready.",641 content: {642 "application/json": {643 schema: {644 type: "object",645 properties: {646 status: { type: "string", enum: ["ready", "degraded"] },647 checks: { type: "object", properties: { database: { type: "boolean" }, cache: { type: "boolean" }, providers: { type: "boolean" } } },648 available_networks: { type: "array", items: { type: "string", enum: ["datacenter", "residential", "isp", "mobile"] } },649 },650 },651 },652 },653 },654 "503": { description: "Degraded (same body with status `degraded`)." },655 },656 },657 },658 },659 components: {660 securitySchemes: {661 bearerAuth: { type: "http", scheme: "bearer", description: "`Authorization: Bearer fch_live_…` or `fch_test_…`." },662 apiKeyHeader: { type: "apiKey", in: "header", name: "X-API-Key", description: "Alternative to the Authorization header. Takes precedence when both are present." },663 },664 headers: {665 "X-Fetcha-Request-ID": { schema: { type: "string" }, description: "Request identifier (req_…). Quote it to support." },666 },667 schemas: {668 Error: errorSchema,669 FetchRequest: fetchRequest,670 FetchResponse: fetchResponse,671 Timing: timing,672 PageMetadata: pageMetadata,673 PageLink: pageLink,674 SessionCreate: sessionCreate,675 Session: session,676 CrawlCreate: crawlCreate,677 CrawlCreated: crawlCreated,678 CrawlJob: crawlJob,679 CrawlStats: crawlStats,680 CrawlPage: crawlPage,681 MapCreate: mapCreate,682 MapResult: mapResult,683 },684 },685 "x-error-catalogue": Object.fromEntries(ERROR_CODES.map((c) => [c, { status: ERROR_HTTP_STATUS[c], message: ERROR_MESSAGES[c] }])),686};687688export const dynamic = "force-static";689690export function GET() {691 return new Response(JSON.stringify(document, null, 2), {692 status: 200,693 headers: {694 "content-type": "application/json; charset=utf-8",695 "content-disposition": 'inline; filename="fetcha-openapi.json"',696 "cache-control": "public, max-age=3600",697 },698 });699}700