# Schema extraction

Turn any page into structured JSON — a working request, the options that matter, the response, what it costs, and the errors to handle.

`POST /v1/schema` fetches a URL and returns data from it as JSON that follows a
[JSON Schema](https://json-schema.org/) you provide. Send a `url` and a `schema` describing the
fields you want, such as a product name and price, and you get back an object with those fields
filled in from the page. An optional `prompt` tells the extraction what to look for.

The call is synchronous: it returns `200` with the result in the response body. There is no
async mode, and a field this endpoint does not know is rejected (see [Errors](#errors)).

## Run an extraction

```bash
curl -X POST https://api.bytekit.com/v1/schema \
  -H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
    "schema": {
      "type": "object",
      "properties": {
        "title": { "type": "string" },
        "price": { "type": "number" },
        "in_stock": { "type": "boolean" }
      }
    },
    "prompt": "Extract the book title, its price as a number, and whether it is in stock."
  }'
```

```json
{
  "data": {
    "title": "A Light in the Attic",
    "price": 51.77,
    "in_stock": true
  },
  "model": "openai/gpt-oss-20b",
  "validated": true,
  "metrics": {
    "scrape_ms": 1006,
    "llm_total_ms": 257,
    "input_tokens": 977,
    "output_tokens": 91,
    "retries": 0,
    "total_ms": 1502
  },
  "scrape": {
    "url": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
    "status_code": 200,
    "content_length": 2303,
    "truncated": false
  },
  "warnings": []
}
```

> This request was run for real and returned `200`. The response is printed as it came back,
> reformatted for reading. The page is a public practice shop for scraping; the timings and token
> counts will differ on your calls.

## Options

`url` and `schema` are required. Send any of the others alongside them.

| Option              | Default              | Description                                                                                                                   |
| ------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `url`               | —                    | The page to extract from. An `http` or `https` URL                                                                            |
| `schema`            | —                    | A JSON Schema describing the output (see [Writing the schema](#writing-the-schema))                                           |
| `prompt`            | none                 | A hint in plain language about what to extract, up to 2000 characters                                                         |
| `model`             | `openai/gpt-oss-20b` | The extraction model: `openai/gpt-oss-20b` or `openai/gpt-oss-120b`. Any other value is rejected with `400 unsupported_model` |
| `country`           | `US`                 | Two-letter country code that sets where the page is fetched from, upper-cased for you. `null` is treated as `US`              |
| `language`          | `null`               | A string or `null`. The API accepts it, but the extraction does not use it today                                              |
| `wait_until`        | `load`               | `load` or `domcontentloaded`. `networkidle` is rejected (see [Rendering options](#rendering-options))                         |
| `wait_for_selector` | `null`               | Must stay unset or `null` (see [Rendering options](#rendering-options))                                                       |
| `delay_ms`          | `0`                  | Must stay `0` (see [Rendering options](#rendering-options)). A value outside `0`–`10000` is a `422 validation_error`          |

### Rendering options

`/v1/schema` reads the page the quick way, without waiting on a browser to render it. Options
that ask for that waiting are therefore refused with `400 unsupported_option`:
`wait_until` set to `networkidle`, a `wait_for_selector`, or a `delay_ms` above `0`. The error
message lists the options this endpoint does not support; it does not say which of yours
triggered it.

```json
{
  "status": "failed",
  "error": {
    "code": "unsupported_option",
    "message": "Schema extraction does not support slow-path options (wait_for_selector, wait_until=networkidle, cookies, delay_ms, custom headers).",
    "http_status": 400,
    "failed_at": "2026-10-09T23:17:40.806Z"
  }
}
```

> This response came from a real request with `"wait_until": "networkidle"`.

Caller-supplied `cookies` and `headers` are no longer accepted: sending either returns
`400 validation_error`.

### Writing the schema

The `schema` is a JSON Schema (draft 2020-12) object. To keep the output reliable, ByteKit
accepts a deliberately small part of the language:

- The root must be an object schema: `"type": "object"` with its `properties`.
- These keywords are accepted: `type`, `properties`, `required`,
  `additionalProperties`, `items`, `enum`, `$defs`, `$ref`, `description`, `title` and
  `default`. A `$ref` can only point into your own `#/$defs`.
- Other keywords are rejected with `422 schema_incompatible`, and `error.details.construct`
  names the one found. They include `pattern`, `format`, `minimum`, `maxLength`, `oneOf`, `anyOf`,
  `allOf`, `not` and `if`/`then`/`else`.
- The schema can be at most 50,000 characters once serialized, and nested `properties` can go
  at most 8 levels deep (the count restarts inside `items`, and `$defs` entries are not counted). A schema over either
  limit, or one that is not valid JSON Schema, returns `422 validation_error`.

```json
{
  "status": "failed",
  "error": {
    "code": "schema_incompatible",
    "message": "schema_incompatible: unsupported construct \"pattern\" — unsupported keyword \"pattern\" found at #/properties/title",
    "http_status": 422,
    "failed_at": "2026-10-09T23:17:43.923Z",
    "details": {
      "construct": "pattern"
    }
  }
}
```

> This response came from a real request whose schema used `pattern`.

Every property you declare is asked for. What a missing value looks like depends on the
property's type: a `string`, `number`, `integer`, `boolean` or `object` property the page does not
contain usually comes back as `null`, and a list (`array`) property usually comes back as an empty
array, `[]`. Asking the book page above for an `isbn` it does not show returned `"isbn": null`.
Asking it for a `title` and a list of literary awards, declared as an `array` of `string` items,
returned `"awards": []`. Both responses had `validated` set to `true`.

A property limited by `enum`, such as `{"type": "string", "enum": ["hardcover", "paperback"]}`,
comes back as `null` when the page holds none of the listed values. The `type` is optional: an
`enum` with no `type` behaves the same way. Your `enum` does not need to list `null`. Asking the
book page above for that `format` next to a `title` returned `"format": null` with `validated` set
to `true`; asked for on its own, the same request returned `200` with `validated` set to `false`
and an `empty_extraction` warning, which charges no credits. A model can also answer with one of the
listed values that the page does not support, so treat an `enum` result as a guess when the page is
silent. An `integer` property with `"enum": [1, 2]` returned `2` on a page that states no edition.

One kind of schema can instead fail with `503 provider_error` when the page has no value to give: a
schema whose only properties are lists of strings, such as
`{"type": "object", "properties": {"awards": {"type": "array", "items": {"type": "string"}}}}`,
when the page has no entries. The same schema returned `"awards": []` on one call and
`503 provider_error` on others, so it does not fail every time. The `title` and `awards` request
above, which asks for a `title` as well, returned `[]`.

This failure can repeat on retry, and it keeps the page's bandwidth charge (see
[Cost](#cost)), so change the schema instead of retrying the same request.

Lists work through `items`. This schema collects every book on a category page:

```bash
curl -X POST https://api.bytekit.com/v1/schema \
  -H "Authorization: Bearer sk_live_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://books.toscrape.com/catalogue/category/books/poetry_23/index.html",
    "schema": {
      "type": "object",
      "properties": {
        "category": { "type": "string" },
        "books": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "title": { "type": "string" },
              "price": { "type": "number" }
            }
          }
        }
      }
    },
    "prompt": "List every book on the page with its price as a number."
  }'
```

> This request was run for real and returned `200` with `"validated": true` and 19 books in
> `data.books`. Its `scrape.content_length` was `4521`. The first two books were:
> `{"title": "A Light in the Attic", "price": 51.77}` and
> `{"title": "The Black Maria", "price": 52.15}`.

## Response

| Field       | Description                                                                                                                                                                |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data`      | The extracted object, shaped by your schema. `null` when `validated` is `false`                                                                                            |
| `model`     | The model that produced the result                                                                                                                                         |
| `validated` | `true` when the output matched your schema, unless it holds values and all of them are `null`. See below for when this is `false`                                          |
| `metrics`   | Timings in milliseconds (`scrape_ms`, `llm_total_ms`, `total_ms`), the token counts (`input_tokens`, `output_tokens`) and `retries`, the number of extra attempts (0 to 2) |
| `scrape`    | The page that was read: its `url`, the `status_code` it answered with (or `null`), `content_length` (its size in bytes, which the bandwidth charge uses) and `truncated`   |
| `warnings`  | Codes for results that are less complete than a clean run. An empty array on a clean run                                                                                   |

A `200` does not always mean you got data. `validated` is `false`, with `data: null`, when the
model refused, when its retries ran out before it produced output that matched your schema, or
when the output holds values and every one of them is `null`. That last case is still `true` if your
schema allows it: each `null` value has to sit at a property whose `type` is a list that includes
`"null"`, for example `["string", "null"]`. Check `validated` before you use `data`.

```json
{
  "data": null,
  "model": "openai/gpt-oss-20b",
  "validated": false,
  "metrics": {
    "scrape_ms": 1455,
    "llm_total_ms": 220,
    "input_tokens": 303,
    "output_tokens": 102,
    "retries": 0,
    "total_ms": 1711
  },
  "scrape": {
    "url": "https://example.com",
    "status_code": 200,
    "content_length": 334,
    "truncated": false
  },
  "warnings": ["empty_extraction"]
}
```

> This response came from a real request that asked `https://example.com`, which has no price,
> for a `price` and a `sku`.

The warning codes are:

| Code                        | Meaning                                                                                |
| --------------------------- | -------------------------------------------------------------------------------------- |
| `content_truncated`         | The page was too long for the model and was cut short, so the result may be incomplete |
| `extract_validation_failed` | The model's output did not match your schema                                           |
| `exhausted_retries`         | The retries ran out before the model produced output that matched your schema          |
| `model_refusal`             | The model declined to extract                                                          |
| `empty_extraction`          | The output matched your schema, but it held values and all of them were `null`         |

## Cost

A request is billed on two things:

- **A fixed credit charge.** Today a request costs 10 credits, whatever the page or the schema, and they are charged only when the extraction validates.
- **The page's bandwidth.** The page is billed at the bytes it took to download, which is the
  `scrape.content_length` in the response, with no multiplier. While the page loads the account is
  charged an estimate of 100 KiB; when the download finishes the estimate is replaced by the real
  size.

When a request fails, what you keep paying depends on how far it got:

| What happened                                                                                                                                                                         | Credits                  | Bandwidth                     |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ----------------------------- |
| Rejected or failed before the page was fetched                                                                                                                                        | Not charged, or refunded | Not charged, or refunded      |
| The page was fetched, but the extraction failed (`provider_error`, `provider_timeout`, `provider_rate_limited`, `content_too_large`, or `schema_incompatible` raised after the fetch) | Refunded                 | Kept: the page was downloaded |
| The page was fetched and the call returned `200` with `validated: false`                                                                                                              | Not charged              | Kept: the page was downloaded |
| A successful extraction                                                                                                                                                               | Charged                  | Charged                       |

[Credit billing](https://bytekit.com/docs/billing/credits) covers the two charges and a failure before the fetch, and
[Rate Limits](https://bytekit.com/docs/guides/rate-limits) covers a failed extraction after it. On real requests,
`credits_scrape` on `GET /v1/usage` rose by 10 after a successful extraction and did not move after
the `validated: false` call or a request for a host that does not exist.

> The credits for this endpoint appear in `credits_scrape` on `GET /v1/usage`.

## Not in the SDKs

`/v1/schema` is not available in the TypeScript SDK, the Python SDK or the CLI: none of them has a
method for it. Call the HTTP endpoint directly, as in the examples above. Any HTTP client works
with the same `Authorization: Bearer` header; see [Authentication](https://bytekit.com/docs/guides/authentication).

## Errors

Errors use the unified envelope described in the [Errors guide](https://bytekit.com/docs/guides/errors). These are the
ones you are most likely to meet on this endpoint. The Errors guide does not list
`unsupported_option`, `unsupported_model`, `schema_incompatible`, `content_too_large`,
`scrape_failed` or the `provider_*` codes, so handle those here.

| Status | Code                                                                                  | When                                                                                                                                                                                 | Retry?                                                                        |
| ------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `400`  | `invalid_url`                                                                         | `url` is not a valid `http` or `https` URL. `error.details` names the field and the reason                                                                                           | No — fix the URL                                                              |
| `400`  | `unsupported_option`                                                                  | `wait_until: "networkidle"`, a `wait_for_selector` or a `delay_ms` above `0` (see [Rendering options](#rendering-options))                                                           | No — remove the option                                                        |
| `400`  | `unsupported_model`                                                                   | `model` is not one of the two listed in [Options](#options)                                                                                                                          | No — use a listed model                                                       |
| `400`  | `validation_error`                                                                    | The body includes `cookies` or `headers`, which are no longer accepted                                                                                                               | No — remove the field                                                         |
| `401`  | `unauthorized`, `invalid_api_key`                                                     | The API key is missing, malformed, or not valid                                                                                                                                      | No — check your key                                                           |
| `402`  | `quota_exceeded`                                                                      | The credit allowance is used up, or the bandwidth balance cannot cover the 100 KiB estimate. The message says which                                                                  | No — add credits or balance, or upgrade                                       |
| `402`  | `spending_cap_reached`, `billing_entitlement_impaired`, `billing_entitlement_expired` | The spending cap or the subscription state blocks the request — see the [Errors guide](https://bytekit.com/docs/guides/errors)                                                                               | No — resolve the billing state first                                          |
| `422`  | `validation_error`                                                                    | The body is not valid JSON, a required field is missing, a field has the wrong type or range, or the body has a field this endpoint does not know (for example `async` or `formats`) | No — fix the request                                                          |
| `422`  | `schema_incompatible`                                                                 | The schema uses something outside [what is supported](#writing-the-schema)                                                                                                           | No — simplify the schema                                                      |
| `422`  | `content_too_large`                                                                   | The page was fetched, but it was too large or too slow to convert for extraction. Credits are refunded and the page's bandwidth is kept (see [Cost](#cost))                          | No — retrying the same request fetches and bills the page again               |
| `429`  | `rate_limited`                                                                        | Too many requests per second                                                                                                                                                         | Yes — wait for the `Retry-After` header, then back off                        |
| `429`  | `provider_rate_limited`                                                               | The extraction service is rate limiting your requests. A `Retry-After` header may be present                                                                                         | Yes — wait for `Retry-After` when present, then back off                      |
| `500`  | `internal_error`                                                                      | An unexpected failure on ByteKit's side. If the request could not be recorded, it did not run                                                                                        | Yes — back off and retry                                                      |
| `503`  | `scrape_failed`                                                                       | The page could not be fetched, or the extraction service could not be reached. Credits are refunded, and so is the bandwidth when the page was not fetched                           | Yes — back off and retry; if the same URL keeps failing, check the page loads |
| `503`  | `provider_timeout`, `provider_unavailable`                                            | The extraction service did not answer in time, or is not available                                                                                                                   | Yes — back off and retry                                                      |
| `503`  | `provider_error`                                                                      | The extraction service returned an error. A schema with only list properties can cause it when the page has no value (see [Writing the schema](#writing-the-schema))                 | Yes — back off and retry once or twice; if it repeats, change the schema      |

`422 schema_incompatible` normally comes from the check on your schema before the page is
fetched, as in the example above. It can also come back after the fetch, when the model cannot
work with the schema; the charges for that case are in the second row of the
[Cost](#cost) table.

A `429 rate_limited` response carries a `Retry-After` header, in seconds. Your own limit depends
on your plan; see [Rate Limits](https://bytekit.com/docs/guides/rate-limits). The `500` and `503` bodies use the same
envelope as the other errors in this table; read `error.code` to tell the `503` cases apart.

```json
{
  "status": "failed",
  "error": {
    "code": "scrape_failed",
    "message": "Schema extraction failed: scrape_failed",
    "http_status": 503,
    "failed_at": "2026-10-09T23:18:17.698Z"
  }
}
```

> This response came from a real request for a host that does not exist.

## Next steps

- [Scraping](https://bytekit.com/docs/guides/scraping) — fetch a page's content as HTML or markdown
- [Errors](https://bytekit.com/docs/guides/errors) — the error envelope and retry guidance
- [Rate Limits](https://bytekit.com/docs/guides/rate-limits) — requests per second and concurrency slots
- [Credit billing](https://bytekit.com/docs/billing/credits) — how credits and the schema extraction charge work
- [API Reference: Schema extraction](https://bytekit.com/docs/api/schema/createschemaextraction) — full request and response schema