Get data you can trust from an API: schemas, validation and retries
Valid JSON, JSON that matches your schema and JSON with the right values are three different things. Let the vendor guarantee the shape, check the values in your own code, retry once with the errors, then hand the item to a person.
- What this is
- How to get a model to fill in fields, such as an invoice total, from a document in a form your own code can check and use.
- Why it matters
- You avoid acting on a value that looks valid but is wrong, because a schema guarantees the shape of a reply, not that it is true.
- The short version
- Let the vendor's schema feature fix the shape, then check values in your code: schema, business rules and quotes.
- Make every field required, use null for missing values, enums with OTHER, and a quote field for each value that matters.
- Retry once with the exact errors, then send the item to a person, and log every failure to fix the prompt or schema.
On this page
Use it when a script asks a model to pull fields out of an email, an invoice or a form, and you need to use those fields without reading every reply.
Not when you have only a handful of documents (read them yourself), or a wrong field would move money on its own (keep a person on every item). Without code, the same idea still works: check each value in a spreadsheet formula or by eye, and read every number you will rely on against the original, as in Photos in, data out.
You need a working API call with a spending cap (Your first API call), Python 3, and about an hour to adapt the example. Remove personal data you do not need first: What not to paste. The checks, the retry rule and the order of the steps are our suggestion.
Short answer
Let the vendor's schema feature guarantee the shape. Check the values in your own code: schema, business rules, and a check that each quoted piece of evidence is really in the document. On a failure, retry once with the errors, then send the item to a person. Log every failure.
Three different failures
| Level | What it means | Example (illustrative) | What catches it |
|---|---|---|---|
| Valid JSON | It parses | {"total": 152.5 cut off, so it does not parse | The vendor's schema feature, plus json.loads |
| Matches the schema | Right fields, types and allowed values | "total": "125.50" as text, or a missing field | The vendor's schema feature, plus your own validator |
| Correct values | The values are true to the document | "total": 152.5 when the invoice says 125.50 | Only your own checks, and a person |
OpenAI separates the first two: JSON mode and Structured Outputs both "ensure valid JSON is produced", but "only Structured Outputs ensure schema adherence". No vendor feature reaches the third. Google: "While output is syntactically correct JSON, always validate values in your application".
Try the three levels on one invoice. Pick a reply, or edit it, and watch which check catches it:
What the vendor features guarantee
Dated box, read 2026-10-10. Check the vendor pages before you build.
| Anthropic | OpenAI | Google Gemini | |
|---|---|---|---|
| Name and switch | Structured outputs. Put the schema in output_config.format with "type": "json_schema". For tools, set strict: true on the tool, next to input_schema. No beta header is needed now. | Structured Outputs. Responses API: text.format with "type": "json_schema" and "strict": true. Chat Completions: response_format with json_schema and "strict": true. | Structured output. The current guide (Interactions API) sets response_format with "mime_type": "application/json" and a schema. The generateContent reference shows response_mime_type and response_schema in generationConfig. |
| Models | Listed on the page, including Haiku 4.5 and 5.5, Sonnet 4.5 to 5.5 and Opus 4.5 to 5.5 | "starting with GPT-4o" | Not listed on the page |
| When the shape can still break | stop_reason of "refusal" or "max_tokens". Enum and const capitalisation is not guaranteed. | Refusals are "programmatically detectable"; the examples check a refusal field | "Very large or deeply nested schemas may be rejected" |
| Schema limits | No recursion, no minimum or maximum, no minLength or maxLength, additionalProperties only false, and some regex features. Per request: 20 strict tools, 24 optional parameters, 16 parameters with union types. | The supported-schemas section did not load for us. OpenAI's own examples set additionalProperties: false, list every field in required, and use ["string", "null"] where a value may be missing. | A subset of JSON Schema: enum, format, minimum, maximum, minItems, maxItems and a few more |
If you use Anthropic tool use with input_schema instead, set strict: true. Without it, Anthropic says, an input may come back as "2" instead of 2, or miss a required field. Also, Anthropic puts required properties before optional ones, so do not depend on key order, and the first request with a new schema is slower while it compiles.
Design the schema
- Required plus
nullkeeps "not in the document" apart from "the model left it out". - Enums stop near-duplicates such as
GBP,£andpounds.OTHERmeans the model is never forced into a wrong one. Compare enum values case-insensitively, as Anthropic advises. - Rules the API ignores, such as
minimumon Anthropic, go in a local copy of the schema that your code checks. - An
unsureflag helps when the input is hard to read, such as a photo of a receipt: a boolean that is true if any field was guessed. An item with it set goes to a person, and nothing is entered. This is our suggestion, and the example code below does not include it. - Keep it small and flat. Large or deep schemas can be rejected. Split a long form into two calls.
Check it in your own code
On every reply, in order:
- Parse and validate against your full local schema (with
jsonschema, or a Pydantic model). - Business rules. Line items add up to the total. Dates are real and in range. IDs exist in your system.
- Quote check. Each quote appears word for word in the input, after collapsing spaces and line breaks. The value appears in its own quote.
- Route. Pass, retry once with the errors, or hand to a person.
In the offline test, a reply giving a total of 152.50 for an invoice that says 125.50 passed the schema check and failed both the business rule and the quote check.
import datetime, json, re
import anthropic
from jsonschema import Draft202012Validator
MODEL = "claude-haiku-5-5"
NULLABLE_STR = {"type": ["string", "null"]}
SCHEMA = {
"type": "object",
"properties": {
"invoice_number": NULLABLE_STR,
"invoice_number_quote": NULLABLE_STR,
"invoice_date": {"type": ["string", "null"], "description": "YYYY-MM-DD"},
"invoice_date_quote": NULLABLE_STR,
"currency": {"type": "string", "enum": ["USD", "EUR", "GBP", "OTHER"]},
"total": {"type": ["number", "null"]},
"total_quote": NULLABLE_STR,
"line_items": {"type": "array", "items": {
"type": "object",
"properties": {"description": {"type": "string"}, "amount": {"type": "number"}},
"required": ["description", "amount"],
"additionalProperties": False}},
},
"required": ["invoice_number", "invoice_number_quote", "invoice_date",
"invoice_date_quote", "currency", "total", "total_quote", "line_items"],
"additionalProperties": False,
}
# Rules we check here ourselves (minimum is on the API's unsupported list; we check the date pattern locally too).
LOCAL_SCHEMA = json.loads(json.dumps(SCHEMA))
LOCAL_SCHEMA["properties"]["total"]["minimum"] = 0
LOCAL_SCHEMA["properties"]["invoice_date"]["pattern"] = r"^\d{4}-\d{2}-\d{2}$"
VALIDATOR = Draft202012Validator(LOCAL_SCHEMA)
PROMPT = ("Extract the invoice fields from the document below. Use null when a value "
"is not in the document; never guess. For each *_quote field, copy the exact "
"text from the document that shows the value.\n\n<document>\n{doc}\n</document>")
def squash(text):
return re.sub(r"\s+", " ", text).strip()
def check(document, raw):
"""Return (data, errors). An empty error list means every check passed."""
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
return None, [f"not valid JSON: {e}"]
if isinstance(data, dict) and isinstance(data.get("currency"), str):
data["currency"] = data["currency"].upper() # enum case is not guaranteed
errors = [f"{'/'.join(map(str, e.path)) or 'root'}: {e.message}"
for e in VALIDATOR.iter_errors(data)]
if errors:
return data, errors
doc = squash(document)
for field in ("invoice_number", "invoice_date", "total"):
value, quote = data[field], data[field + "_quote"]
if value is not None and not quote:
errors.append(f"{field}: value given without a quote")
if quote and squash(quote) not in doc:
errors.append(f"{field}_quote: not found word for word in the document")
if data["invoice_number"] and data["invoice_number_quote"] \
and data["invoice_number"] not in data["invoice_number_quote"]:
errors.append("invoice_number: not in its own quote")
if data["total"] is not None and data["total_quote"]:
numbers = [float(n.replace(",", "")) for n in re.findall(r"\d[\d,]*\.?\d*", data["total_quote"])]
if data["total"] not in numbers:
errors.append("total: not in its own quote")
if data["total"] is not None and data["line_items"]:
items = round(sum(i["amount"] for i in data["line_items"]), 2)
if abs(items - data["total"]) > 0.01:
errors.append(f"total: {data['total']} but line items add up to {items}")
if data["invoice_date"]:
try:
day = datetime.date.fromisoformat(data["invoice_date"])
if not datetime.date(2020, 1, 1) <= day <= datetime.date.today():
errors.append(f"invoice_date: {day} is outside the expected range")
except ValueError:
errors.append(f"invoice_date: {data['invoice_date']} is not a real date")
return data, errors
def ask(client, document, feedback=None):
messages = [{"role": "user", "content": PROMPT.format(doc=document)}]
if feedback:
messages += [{"role": "assistant", "content": feedback["raw"]},
{"role": "user", "content": "Your answer failed these checks:\n- "
+ "\n- ".join(feedback["errors"]) + "\nReturn a corrected answer."}]
response = client.messages.create(
model=MODEL, max_tokens=1024, messages=messages,
output_config={"format": {"type": "json_schema", "schema": SCHEMA}})
if response.stop_reason in ("refusal", "max_tokens"):
return None, [f"stop_reason: {response.stop_reason}"]
return next(b.text for b in response.content if b.type == "text"), []
def extract(document, doc_id, client=None, log_path="extract_failures.jsonl"):
client = client or anthropic.Anthropic() # reads ANTHROPIC_API_KEY
feedback = None
for attempt in (1, 2): # one try, one retry, never a loop
raw, errors = ask(client, document, feedback)
data, errors = check(document, raw) if raw else (None, errors)
if not errors:
return {"status": "ok", "data": data, "attempts": attempt}
with open(log_path, "a") as log:
log.write(json.dumps({"doc_id": doc_id, "attempt": attempt, "model": MODEL,
"at": datetime.datetime.now().isoformat(),
"errors": errors, "raw": raw}) + "\n")
if raw is None:
break # refusal or cut off: feedback will not fix it (for a cut-off, raise max_tokens)
feedback = {"raw": raw, "errors": errors}
return {"status": "needs_person", "errors": errors}
Run it with pip install anthropic jsonschema and your key in ANTHROPIC_API_KEY, then extract(text, doc_id="<DOC_ID>"). check() was tested on 16 sample replies and extract() with a stubbed API (anthropic 1.13.0, jsonschema 4.26.0, Python 3.13). It has not been run against the live API.
Replace the following:
<DOC_ID>: your own ID for the document, such as a file name, so a failure can be traced back.MODEL: any model the structured outputs page lists.claude-haiku-5-5is Anthropic's cheapest.SCHEMAand the field names: your own fields, each important one with a_quotepartner.- The date range and the rules in
check(): your own business rules, such as "the supplier ID is in our supplier list".
Retry once, then a person
A loop can run up your bill on one bad document, so keep the cap from Your first API call as well.
Log every failure
The example writes one line per failed attempt to extract_failures.jsonl: document ID, attempt, model, time, errors and raw reply. Count the errors by type each week. The same error on many documents points at the prompt or the schema. Raw replies can hold personal data, so keep the log private and delete it on a schedule (What not to paste).
Steps
| # | Step | Who | Time | Check |
|---|---|---|---|---|
| 1 | List the fields, which ones matter, and the business rules that tie them together. | Person | 15 min | Each field has a type, and each rule could be checked by code. |
| 2 | Write the schema: all fields required, null for missing, enums with OTHER, a _quote per important value. | Person | 15 min | It uses nothing on the vendor's unsupported list. If it fails: move that rule to the local schema. |
| 3 | Adapt check() and test it on hand-made replies: one good, and one for each kind of failure. | Person | 20 min | Every bad reply fails, and the good one passes. |
| 4 | Run it on 20 real documents and read every result. | Person and AI | 20 min | No reply that passed check() has a wrong value. If it fails: add a rule that would have caught it. |
| 5 | Run the batch. Read every needs_person item and the failure log. | Person and AI | varies | Every item is ok or handled by a person. |
| 6 | Hand-check a random sample of the ok items to measure how often they are still wrong. | Person | 30 min | You have an error rate with a range, as in One prompt, many rows. |
How to tell it worked
- Every document ends as
okorneeds_person, and nothing crashes. - Every hand-made bad reply fails
check()with an error that names the field. - The share of
needs_personfalls as you fix the prompt from the log. - A random sample of
okitems meets your error target (One prompt, many rows). Re-run it when you change model (A personal eval set).
Sources
- Anthropic. Structured outputs. Vendor documentation, read 2026-10-10. Link
- Anthropic. Strict tool use. Vendor documentation, read 2026-10-10. Link
- Anthropic. Define tools. Vendor documentation, read 2026-10-10. Link
- Anthropic. Models overview. Vendor documentation, read 2026-10-10. Link
- OpenAI. Structured model outputs. Vendor documentation, read 2026-10-10. Link
- Google. Structured output, Gemini API. Vendor documentation, read 2026-10-10. Link
Changes
- v1.010 Oct 2026first version
- Sources
- 6
- Updated
- 10 Oct 2026
- Last checked
- 11 Oct 2026