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.
For AI agents: https://bytekit.com/llms.txt — every docs page is available as markdown by appending .md
POST /v1/schema fetches a URL and returns data from it as JSON that follows a
JSON Schema 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).
Run an extraction
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."
}'{
"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) |
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) |
wait_for_selector | null | Must stay unset or null (see Rendering options) |
delay_ms | 0 | Must stay 0 (see 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.
{
"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 itsproperties. - These keywords are accepted:
type,properties,required,additionalProperties,items,enum,$defs,$ref,description,titleanddefault. A$refcan only point into your own#/$defs. - Other keywords are rejected with
422 schema_incompatible, anderror.details.constructnames the one found. They includepattern,format,minimum,maxLength,oneOf,anyOf,allOf,notandif/then/else. - The schema can be at most 50,000 characters once serialized, and nested
propertiescan go at most 8 levels deep (the count restarts insideitems, and$defsentries are not counted). A schema over either limit, or one that is not valid JSON Schema, returns422 validation_error.
{
"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), so change the schema instead of retrying the same request.
Lists work through items. This schema collects every book on a category page:
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
200with"validated": trueand 19 books indata.books. Itsscrape.content_lengthwas4521. 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.
{
"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 apriceand asku.
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_lengthin 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 covers the two charges and a failure before the fetch, and
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_scrapeonGET /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.
Errors
Errors use the unified envelope described in the Errors guide. 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) | No — remove the option |
400 | unsupported_model | model is not one of the two listed in 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 | 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 | 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) | 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) | 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 table.
A 429 rate_limited response carries a Retry-After header, in seconds. Your own limit depends
on your plan; see 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.
{
"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 — fetch a page's content as HTML or markdown
- Errors — the error envelope and retry guidance
- Rate Limits — requests per second and concurrency slots
- Credit billing — how credits and the schema extraction charge work
- API Reference: Schema extraction — full request and response schema