{
  "openapi": "3.0.3",
  "info": {
    "title": "Grabber API",
    "version": "1.0.0",
    "description": "Grabber loads one web page and returns it as markdown, HTML, text, links, or\nthe raw body. It is made for AI agents and scripts.\n\nThere is one endpoint, `GET /fetch`. Every call needs\n`Authorization: Bearer <key>`. Create a key at\nhttps://dashboard.grabber.sh/keys. Keys start with `gsh_`. A key goes in the\nheader only: `?key=` is refused like any other unknown parameter.\n\n## What it costs\n\nGrabber is prepaid. A fetch costs **$0.001**, or **$0.005** with\n`js_render=true`. You pay only for a page that was loaded: any error\ncosts nothing. A page whose own server answered 404 or 500 is still a loaded\npage, so it is a `200` and it is charged. Read `data.fetch.httpStatusCode`.\n\nEvery answer that got as far as billing carries `X-Grabber-Cost` (what this\nrequest cost, in USD) and `X-Grabber-Balance` (what is left). With\n`unwrap=true` the body is the content itself with no envelope, so these headers\nare the only place the spend is reported.\n\n## Limits\n\n60 requests per minute per key. Every request with a valid key counts,\nincluding one refused for a bad query or for lack of credit. Over the limit the\nanswer is `429` with a `Retry-After` header.\n\nA rendered fetch (`js_render=true`) can take up to 120 seconds. Use a client\ntimeout of at least 130 seconds.\n\n## Errors\n\nEvery error has the shape `{ status, message, detail }`, plus a `hint` when\nthere is something the caller can do about it. `detail` is a stable code to\nbranch on; `message` is the HTTP reason phrase. Nothing is ever cached:\nevery answer has `Cache-Control: no-store`.\n\n## Example\n\n```\ncurl -G https://api.grabber.sh/fetch \\\n  -H \"Authorization: Bearer $GRABBER_API_KEY\" \\\n  --data-urlencode \"url=https://example.com/\" \\\n  -d \"formats=markdown\"\n```\n\nOpening https://api.grabber.sh/ in a browser returns a short JSON pointer to\nthis endpoint. Any other path is `404 not_found`.\n"
  },
  "servers": [
    {
      "url": "https://api.grabber.sh"
    }
  ],
  "tags": [
    {
      "name": "Fetch",
      "description": "Load one URL and return its content."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/fetch": {
      "get": {
        "tags": [
          "Fetch"
        ],
        "operationId": "fetchPage",
        "summary": "Fetch a URL",
        "description": "Loads `url` and returns the selected representations. Only `GET` is\naccepted: any other method is `405` with `Allow: GET`.\n\nAn omitted `formats` list is markdown for HTML and PDF, text for other\ntextual types, and raw for images and other binary types.\n\n`unwrap=true` returns the first requested format this content can\nproduce, as the response body, with that format's content type:\n`text/markdown`, `text/html`, `text/plain`, a JSON array of links, or\nthe target's own media type for `raw`. `includeMetadata` is ignored\nwhen `unwrap` is true. If none of the requested formats can be\nproduced, the answer is `502 format_unavailable`.\n\nThe `url` must be `http` or `https`, must not include a username or\npassword, and must be at most 8192 characters. Localhost, `.local`\nnames, private and loopback addresses, and cloud metadata hosts are\n`400 blocked_url`. A redirect to one of those hosts is\n`502 blocked_url`. A fetched body over 8 MiB is `502 body_too_large`.\n\nUnknown parameters are refused, so a typo is reported instead of\nignored. If the address you want to fetch has its own query string,\npercent-encode the whole `url` value, or its `&` will be read as the\nstart of another parameter.\n",
        "parameters": [
          {
            "name": "url",
            "in": "query",
            "required": true,
            "description": "The address to fetch. Percent-encode it. Give it exactly once.\n",
            "schema": {
              "type": "string",
              "maxLength": 8192
            },
            "example": "https://example.com/start"
          },
          {
            "name": "formats",
            "in": "query",
            "required": false,
            "description": "What to return, in the order you want it: a comma-separated list\n(`formats=markdown,links`). Repeating the parameter\n(`formats=markdown&formats=links`) works too. An empty item is\nrefused.\n",
            "style": "form",
            "explode": false,
            "schema": {
              "type": "array",
              "minItems": 1,
              "items": {
                "type": "string",
                "enum": [
                  "markdown",
                  "html",
                  "text",
                  "links",
                  "raw"
                ]
              }
            },
            "example": [
              "markdown",
              "links"
            ]
          },
          {
            "name": "unwrap",
            "in": "query",
            "required": false,
            "description": "`true` returns the first format as the response body, instead of\nthe JSON envelope. Only `true` or `false`, given at most once.\n",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "includeMetadata",
            "in": "query",
            "required": false,
            "description": "`true` adds `data.metadata` (title, description, language, Open\nGraph tags, JSON-LD, links, phones, emails). Ignored when `unwrap`\nis true. Only `true` or `false`, given at most once.\n",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "js_render",
            "in": "query",
            "required": false,
            "description": "`true` runs the page in a headless browser first, so content that\ndepends on JavaScript is included. It costs $0.005 instead of\n$0.001 and can take up to 120 seconds. Only `true` or `false`,\ngiven at most once.\n",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page was loaded, and charged.",
            "headers": {
              "X-Grabber-Cost": {
                "$ref": "#/components/headers/XGrabberCost"
              },
              "X-Grabber-Balance": {
                "$ref": "#/components/headers/XGrabberBalance"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/FetchSuccess"
                    },
                    {
                      "$ref": "#/components/schemas/LinkList"
                    }
                  ]
                },
                "examples": {
                  "page": {
                    "summary": "JSON envelope",
                    "value": {
                      "status": 200,
                      "message": "OK",
                      "data": {
                        "url": "https://example.com/start",
                        "fetch": {
                          "loadedUrl": "https://example.com/final",
                          "loadedTime": "2026-10-21T16:36:49.840Z",
                          "httpStatusCode": 200,
                          "contentLengthBytes": 9,
                          "contentType": "text/html",
                          "encoding": "utf8"
                        },
                        "markdown": "ok"
                      }
                    }
                  },
                  "rendered": {
                    "summary": "Rendered HTML and metadata",
                    "value": {
                      "status": 200,
                      "message": "OK",
                      "data": {
                        "url": "https://example.com/start",
                        "fetch": {
                          "loadedUrl": "https://example.com/start",
                          "loadedTime": "2026-10-21T16:36:49.840Z",
                          "httpStatusCode": 200,
                          "contentLengthBytes": 48,
                          "contentType": "text/html",
                          "encoding": "utf8"
                        },
                        "metadata": {
                          "canonicalUrl": null,
                          "title": "Hi",
                          "description": null,
                          "author": null,
                          "keywords": null,
                          "languageCode": "en",
                          "openGraph": [],
                          "jsonLd": [],
                          "links": [],
                          "phones": [],
                          "emails": []
                        },
                        "html": "<p>rendered</p>",
                        "markdown": "rendered"
                      }
                    }
                  },
                  "links": {
                    "summary": "Unwrapped link list",
                    "value": [
                      "https://example.com/docs"
                    ]
                  }
                }
              },
              "text/markdown": {
                "schema": {
                  "type": "string"
                },
                "example": "ok"
              },
              "text/html": {
                "schema": {
                  "type": "string"
                },
                "example": "<p>ok</p>"
              },
              "text/plain": {
                "schema": {
                  "type": "string"
                },
                "example": "hello"
              },
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "The query or the URL was refused, and nothing was charged.\n`X-Grabber-Cost` and `X-Grabber-Balance` are present when the\nrequest got as far as billing, which is the case for every\nrefusal except a malformed query.\n",
            "headers": {
              "X-Grabber-Cost": {
                "$ref": "#/components/headers/XGrabberCost"
              },
              "X-Grabber-Balance": {
                "$ref": "#/components/headers/XGrabberBalance"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BadRequest"
                },
                "examples": {
                  "missingUrl": {
                    "summary": "No url, or more than one",
                    "value": {
                      "status": 400,
                      "message": "Bad Request",
                      "detail": "invalid_query",
                      "hint": "Give exactly one address to fetch, for example ?url=https://example.com/"
                    }
                  },
                  "unknownParameter": {
                    "summary": "A parameter that does not exist",
                    "value": {
                      "status": 400,
                      "message": "Bad Request",
                      "detail": "invalid_query",
                      "hint": "Unknown parameter \"key\". If it is part of the address to fetch, percent-encode the whole url value."
                    }
                  },
                  "unknownFormat": {
                    "summary": "A format that does not exist",
                    "value": {
                      "status": 400,
                      "message": "Bad Request",
                      "detail": "invalid_query",
                      "hint": "One of the values in formats is not supported. Use markdown, html, text, links or raw."
                    }
                  },
                  "invalidUrl": {
                    "summary": "The url is not http(s)",
                    "value": {
                      "status": 400,
                      "message": "Bad Request",
                      "detail": "invalid_url"
                    }
                  },
                  "blockedUrl": {
                    "summary": "The url points at a blocked host",
                    "value": {
                      "status": 400,
                      "message": "Bad Request",
                      "detail": "blocked_url"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "The key is missing, malformed, unknown, or deleted.",
            "headers": {
              "WWW-Authenticate": {
                "description": "Always `Bearer`.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "Bearer"
                  ]
                }
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Unauthorized"
                },
                "example": {
                  "status": 401,
                  "message": "Unauthorized",
                  "detail": "invalid_api_key"
                }
              }
            }
          },
          "402": {
            "description": "The balance is below the price of this request. Nothing was\ncharged and nothing was fetched.\n",
            "headers": {
              "X-Grabber-Cost": {
                "$ref": "#/components/headers/XGrabberCost"
              },
              "X-Grabber-Balance": {
                "$ref": "#/components/headers/XGrabberBalance"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InsufficientCredits"
                },
                "example": {
                  "status": 402,
                  "message": "Payment Required",
                  "detail": "insufficient_credits",
                  "hint": "Add credits at https://dashboard.grabber.sh/billing"
                }
              }
            }
          },
          "405": {
            "description": "The method is not `GET`.",
            "headers": {
              "Allow": {
                "description": "Always `GET`.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "GET"
                  ]
                }
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MethodNotAllowed"
                },
                "example": {
                  "status": 405,
                  "message": "Method Not Allowed",
                  "detail": "method_not_allowed"
                }
              }
            }
          },
          "429": {
            "description": "More than 60 requests in this minute for this key. Wait\n`Retry-After` seconds.\n",
            "headers": {
              "Retry-After": {
                "description": "Seconds until the next minute begins.",
                "schema": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 60
                },
                "example": 23
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimited"
                },
                "example": {
                  "status": 429,
                  "message": "Too Many Requests",
                  "detail": "rate_limited"
                }
              }
            }
          },
          "500": {
            "description": "Something failed on Grabber's side. Nothing was charged. Try\nagain; if it keeps happening, it is not caused by your request.\n",
            "headers": {
              "X-Grabber-Cost": {
                "$ref": "#/components/headers/XGrabberCost"
              },
              "X-Grabber-Balance": {
                "$ref": "#/components/headers/XGrabberBalance"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InternalError"
                },
                "example": {
                  "status": 500,
                  "message": "Internal Server Error",
                  "detail": "internal_error"
                }
              }
            }
          },
          "502": {
            "description": "The page could not be loaded or unwrapped, and nothing was\ncharged. `upstream_unavailable` means Grabber could not get an\nanswer from its fetching service; the others say why the page\nitself failed.\n",
            "headers": {
              "X-Grabber-Cost": {
                "$ref": "#/components/headers/XGrabberCost"
              },
              "X-Grabber-Balance": {
                "$ref": "#/components/headers/XGrabberBalance"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/NoStore"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BadGateway"
                },
                "examples": {
                  "failed": {
                    "summary": "The load failed",
                    "value": {
                      "status": 502,
                      "message": "Bad Gateway",
                      "detail": "fetch_failed"
                    }
                  },
                  "blockedRedirect": {
                    "summary": "The page redirected to a blocked host",
                    "value": {
                      "status": 502,
                      "message": "Bad Gateway",
                      "detail": "blocked_url"
                    }
                  },
                  "tooLarge": {
                    "summary": "The body is over 8 MiB",
                    "value": {
                      "status": 502,
                      "message": "Bad Gateway",
                      "detail": "body_too_large"
                    }
                  },
                  "unavailable": {
                    "summary": "Unwrap has no format it can return",
                    "value": {
                      "status": 502,
                      "message": "Bad Gateway",
                      "detail": "format_unavailable"
                    }
                  },
                  "upstream": {
                    "summary": "Grabber could not reach its fetching service",
                    "value": {
                      "status": 502,
                      "message": "Bad Gateway",
                      "detail": "upstream_unavailable"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key from https://dashboard.grabber.sh/keys. It starts with `gsh_`.\nThe header is the only place it is accepted.\n"
      }
    },
    "headers": {
      "XGrabberCost": {
        "description": "What this request cost, in US dollars: `0.001`, `0.005`, or `0` when\nnothing was charged.\n",
        "schema": {
          "type": "string",
          "pattern": "^[0-9]+(\\.[0-9]+)?$"
        },
        "example": "0.001"
      },
      "XGrabberBalance": {
        "description": "What is left of your credit after this request, in US dollars.",
        "schema": {
          "type": "string",
          "pattern": "^[0-9]+(\\.[0-9]+)?$"
        },
        "example": "4.213"
      },
      "NoStore": {
        "description": "Always `no-store`. Answers are never cached.",
        "schema": {
          "type": "string",
          "enum": [
            "no-store"
          ]
        }
      }
    },
    "schemas": {
      "FetchSuccess": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "data"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              200
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "OK"
            ]
          },
          "data": {
            "$ref": "#/components/schemas/FetchResult"
          }
        }
      },
      "FetchResult": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "url",
          "fetch"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "The URL that was requested.",
            "example": "https://example.com/start"
          },
          "fetch": {
            "$ref": "#/components/schemas/FetchInfo"
          },
          "metadata": {
            "$ref": "#/components/schemas/PageMetadata"
          },
          "markdown": {
            "type": "string"
          },
          "html": {
            "type": "string"
          },
          "text": {
            "type": "string"
          },
          "links": {
            "$ref": "#/components/schemas/LinkList"
          },
          "raw": {
            "type": "string",
            "description": "The original body. UTF-8 text when `fetch.encoding` is `utf8`,\nand base64 when `fetch.encoding` is `base64`.\n"
          }
        }
      },
      "FetchInfo": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "loadedUrl",
          "loadedTime",
          "httpStatusCode",
          "contentLengthBytes",
          "contentType",
          "encoding"
        ],
        "properties": {
          "loadedUrl": {
            "type": "string",
            "description": "The URL after redirects.",
            "example": "https://example.com/final"
          },
          "loadedTime": {
            "type": "string",
            "format": "date-time",
            "example": "2026-10-21T16:36:49.840Z"
          },
          "httpStatusCode": {
            "type": "integer",
            "description": "Status returned by the target, including 4xx and 5xx.",
            "example": 200
          },
          "contentLengthBytes": {
            "type": "integer",
            "minimum": 0,
            "example": 9
          },
          "contentType": {
            "type": "string",
            "example": "text/html"
          },
          "encoding": {
            "type": "string",
            "enum": [
              "utf8",
              "base64"
            ]
          }
        }
      },
      "PageMetadata": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "canonicalUrl",
          "title",
          "description",
          "author",
          "keywords",
          "languageCode",
          "openGraph",
          "jsonLd",
          "links",
          "phones",
          "emails"
        ],
        "properties": {
          "canonicalUrl": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "author": {
            "type": "string",
            "nullable": true
          },
          "keywords": {
            "type": "string",
            "nullable": true
          },
          "languageCode": {
            "type": "string",
            "nullable": true
          },
          "openGraph": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OpenGraphTag"
            }
          },
          "jsonLd": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            }
          },
          "links": {
            "$ref": "#/components/schemas/LinkList"
          },
          "phones": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "emails": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "OpenGraphTag": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "property",
          "content"
        ],
        "properties": {
          "property": {
            "type": "string",
            "example": "og:title"
          },
          "content": {
            "type": "string",
            "example": "Hi"
          }
        }
      },
      "LinkList": {
        "type": "array",
        "items": {
          "type": "string"
        },
        "example": [
          "https://example.com/docs"
        ]
      },
      "BadRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "detail"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              400
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "Bad Request"
            ]
          },
          "detail": {
            "type": "string",
            "description": "`invalid_query`: the query is wrong, and `hint` says how.\n`invalid_url`: the url is not `http` or `https`, has a\nusername or password, or is too long.\n`blocked_url`: the url points at a host that cannot be fetched.\n",
            "enum": [
              "invalid_query",
              "invalid_url",
              "blocked_url"
            ]
          },
          "hint": {
            "type": "string",
            "description": "What to change. Present for `invalid_query`."
          }
        }
      },
      "Unauthorized": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "detail"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              401
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "Unauthorized"
            ]
          },
          "detail": {
            "type": "string",
            "enum": [
              "invalid_api_key"
            ]
          }
        }
      },
      "InsufficientCredits": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "detail",
          "hint"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              402
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "Payment Required"
            ]
          },
          "detail": {
            "type": "string",
            "enum": [
              "insufficient_credits"
            ]
          },
          "hint": {
            "type": "string",
            "description": "Where to add credit."
          }
        }
      },
      "MethodNotAllowed": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "detail"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              405
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "Method Not Allowed"
            ]
          },
          "detail": {
            "type": "string",
            "enum": [
              "method_not_allowed"
            ]
          }
        }
      },
      "RateLimited": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "detail"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              429
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "Too Many Requests"
            ]
          },
          "detail": {
            "type": "string",
            "enum": [
              "rate_limited"
            ]
          }
        }
      },
      "InternalError": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "detail"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              500
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "Internal Server Error"
            ]
          },
          "detail": {
            "type": "string",
            "enum": [
              "internal_error"
            ]
          }
        }
      },
      "BadGateway": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "status",
          "message",
          "detail"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "enum": [
              502
            ]
          },
          "message": {
            "type": "string",
            "enum": [
              "Bad Gateway"
            ]
          },
          "detail": {
            "type": "string",
            "description": "`fetch_failed`: the page could not be loaded.\n`blocked_url`: the page redirected to a host that cannot be fetched.\n`body_too_large`: the page is over 8 MiB.\n`format_unavailable`: `unwrap=true`, and none of the requested\nformats can be produced for this content.\n`upstream_unavailable`: Grabber could not get an answer from its\nfetching service.\n",
            "enum": [
              "fetch_failed",
              "blocked_url",
              "body_too_large",
              "format_unavailable",
              "upstream_unavailable"
            ]
          }
        }
      }
    }
  }
}
