This medical OCR API example in Python uploads a fictional EOB, waits for the extraction job, prints every field with its confidence score and lists the fields below 0.90 for review. The endpoint, api.example.com, is made up for illustration. Schema Care offers no API, SDK, webhooks, FHIR, 835 or 837 output; nothing here describes one.
Setting up the Python OCR API client and credentials
A Python OCR API client is the small piece of code that holds the base URL, the API key and the network settings every request shares. Set it up once with an HTTP library, read the key from an environment variable, and give every request a timeout, so a slow server cannot hang the script.
This example uses the requests library. Its quickstart guide on timeouts warns that nearly all production code should pass a timeout, and that if none is set, requests do not time out. The requests advanced guide explains the two parts of a timeout: the connect timeout for opening the connection and the read timeout for waiting between bytes from the server. A tuple such as (3.05, 30) sets them separately.
The same guide describes Session objects, which keep headers across requests and reuse the underlying connection through connection pooling. Put the authorization header on the session and every call carries it.
The httpx library works too, with a similar interface. The httpx timeouts documentation says it enforces timeouts by default and raises an exception after 5 seconds of network inactivity, so a script using httpx gets a timeout even when the developer forgets one.
Keep the key out of the source file. Python's documentation for os.environ describes it as a mapping of strings that represents the process environment. Reading the key with os.environ["OCR_API_KEY"] fails at once if the variable is missing, which is better than sending unauthenticated requests and decoding the errors later.
Running the example
To try the shape of the script, install requests with pip, save the code as ocr_example.py, set OCR_API_KEY in your shell and pass the path of a fictional PDF as the only argument. Against the placeholder host the upload will fail, because api.example.com is not a real service. The IETF's reserved example domain names, RFC 2606 set aside example.com for use as an example, which is why it appears here. To point the script at a service you do have access to, set OCR_API_BASE_URL and adjust the field names to match that service's documentation.
Keep the constants at the top of the file. The threshold, poll interval and maximum wait are the values a reviewer or operator is most likely to ask about, and having them in one place makes a code review quicker than hunting through functions.
The complete example script below runs against the placeholder host. Every endpoint, header, field name and status value in it is invented for this post.
"""Example only: a client for a made-up medical OCR API at api.example.com.
Schema Care does not offer an API. The endpoints, headers, field names and
response shapes below are invented to illustrate the pattern.
"""
import os
import sys
import time
import uuid
import requests
BASE_URL = os.environ.get("OCR_API_BASE_URL", "https://api.example.com/v1")
API_KEY = os.environ["OCR_API_KEY"] # fails fast if the key is not set
TIMEOUT = (3.05, 30) # (connect, read) in seconds
THRESHOLD = 0.90 # fields below this go to review
POLL_SECONDS = 5
MAX_WAIT_SECONDS = 600
MAX_ATTEMPTS = 5
def retry_delay(response, attempt):
"""Seconds to wait before the next attempt."""
value = response.headers.get("Retry-After", "") if response is not None else ""
if value.isdigit():
return int(value)
return min(2 ** attempt, 60) # 1, 2, 4, 8 ... capped at 60
def send(session, method, url, **kwargs):
"""Send one request, retrying on network errors, 429 and 5xx."""
for attempt in range(MAX_ATTEMPTS):
last = attempt == MAX_ATTEMPTS - 1
try:
response = session.request(method, url, timeout=TIMEOUT, **kwargs)
except (requests.ConnectionError, requests.Timeout):
if last:
raise
time.sleep(retry_delay(None, attempt))
continue
if response.status_code == 429 or response.status_code >= 500:
if last:
response.raise_for_status()
time.sleep(retry_delay(response, attempt))
continue
response.raise_for_status() # any other 4xx stops the script
return response
def upload(session, path):
"""Upload one PDF and return the job ID from the example API."""
with open(path, "rb") as f: # binary mode, read once
content = f.read()
files = {"file": (os.path.basename(path), content, "application/pdf")}
data = {"document_type": "eob"}
headers = {"Idempotency-Key": str(uuid.uuid4())} # same key on every retry
response = send(session, "POST", f"{BASE_URL}/documents",
files=files, data=data, headers=headers)
return response.json()["job_id"]
def wait_for_job(session, job_id):
"""Poll the example job endpoint until it finishes or time runs out."""
deadline = time.monotonic() + MAX_WAIT_SECONDS
while time.monotonic() < deadline:
job = send(session, "GET", f"{BASE_URL}/jobs/{job_id}").json()
if job["status"] == "succeeded":
return job["result"]
if job["status"] == "failed":
raise RuntimeError(f"job {job_id} failed: {job.get('error')}")
time.sleep(POLL_SECONDS)
raise TimeoutError(f"job {job_id} still running after {MAX_WAIT_SECONDS}s")
def report(result):
"""Print every field, then the ones under the threshold."""
fields = result.get("fields", [])
needs_review = []
for field in fields:
name = field["name"]
value = field.get("value")
confidence = field.get("confidence", 0.0)
page = field.get("page")
print(f"{name:<24} {str(value):<20} {confidence:.2f} page {page}")
if value is None or confidence < THRESHOLD:
needs_review.append(field)
print(f"\n{len(needs_review)} of {len(fields)} fields under {THRESHOLD:.2f}:")
for field in needs_review:
print(f" {field['name']} on page {field.get('page')}, box {field.get('box')}")
return needs_review
def main():
if len(sys.argv) != 2:
sys.exit("usage: python ocr_example.py fictional-eob.pdf")
with requests.Session() as session:
session.headers.update({"Authorization": f"Bearer {API_KEY}"})
job_id = upload(session, sys.argv[1])
print(f"job {job_id} accepted, waiting ...")
result = wait_for_job(session, job_id)
report(result)
if __name__ == "__main__":
main()
Uploading a document to a medical OCR API in Python
Uploading a document to a medical OCR API in Python usually means a multipart form POST: the file goes in one part, options such as the document type go in others, and the API answers with a job ID instead of the extracted data. The job ID is what the script uses to ask for results later.
The requests guide to posting a multipart-encoded file shows the files= argument, including the tuple form that sets the file name and content type. The same page strongly recommends opening files in binary mode, because requests may compute the Content-Length header from the byte count. The example reads the file once into memory, which keeps a retry from sending an empty body after the first attempt has read the file to its end.
The upload function also sends an Idempotency-Key header. That header is part of the made-up API, not a standard every service accepts. HTTP POST is not one of the methods that the IETF HTTP semantics specification, RFC 9110, lists as idempotent, so a retried upload could create a second job. A key that stays the same across retries lets a server that supports it recognize the repeat. How a real service should handle that key is covered in the guide to retries and idempotency keys for document uploads.
Upload only fictional pages while you build. The file name in the usage line, fictional-eob.pdf, stands for a page like the ones described in the guide to building a synthetic medical document test set: invented patient, test-prefixed member ID, amounts that add up.
Should a Python OCR script poll or use a webhook?
A Python OCR script that runs from a terminal or a scheduled job should poll: it asks for the job status every few seconds until the job finishes or a deadline passes. A webhook, where the API calls your server when the job is done, suits a long-running web service that can receive requests from the internet.
Polling fits a script because the script is already waiting. It has no public address for an API to call back, and setting one up for a test run is more work than the polling loop. The wait_for_job function polls every 5 seconds, stops on a succeeded or failed status, and gives up after 10 minutes. It uses time.monotonic() for the deadline, so a change to the system clock cannot stretch or cut the wait.
Polling has costs. Each status check is a request, and a short interval on many jobs adds up against a rate limit. Pick an interval that matches how long jobs usually take: there is little point asking every second about a job that takes a minute.
A webhook removes the waiting but adds a server, a public URL and a way to prove each callback really came from the API. That proof step is covered in the guide on how a webhook receiver should verify the sender. A production system can use both: a webhook for speed and an occasional poll to catch any callback that never arrived.
How do I parse extracted fields and confidence in Python?
Parse extracted fields in Python by reading the JSON response into a dictionary, looping over the fields list, and pulling out each field's name, value, confidence score and page reference. A confidence score is the extraction system's estimate, from 0 to 1, that the value is correct. Compare it with your threshold and keep the page reference for review.
The requests guide to JSON response content notes that a successful call to r.json() does not mean the request succeeded, since some servers return JSON error details with a 500 status. That is why send calls raise_for_status() before any code reads the body.
A finished job from the example API returns a body shaped like this. All values are fictional, and the field names are invented for the example.
{
"job_id": "job_example_0001",
"status": "succeeded",
"result": {
"document_type": "eob",
"fields": [
{"name": "patient_name", "value": "Ada Testpatient", "confidence": 0.98, "page": 1, "box": [72, 140, 260, 156]},
{"name": "member_id", "value": "ZZTEST000123", "confidence": 0.96, "page": 1, "box": [400, 140, 520, 156]},
{"name": "claim_number", "value": "TEST-CLM-0001", "confidence": 0.95, "page": 1, "box": [72, 170, 210, 186]},
{"name": "billed_amount", "value": "200.00", "confidence": 0.97, "page": 1, "box": [300, 260, 360, 276]},
{"name": "allowed_amount", "value": "150.00", "confidence": 0.84, "page": 1, "box": [370, 260, 430, 276]},
{"name": "plan_paid", "value": "120.00", "confidence": 0.93, "page": 1, "box": [510, 260, 570, 276]},
{"name": "adjustment_code", "value": "C0-45", "confidence": 0.71, "page": 1, "box": [580, 260, 620, 276]}
]
}
}
Run against that body, the report function would print:
patient_name Ada Testpatient 0.98 page 1
member_id ZZTEST000123 0.96 page 1
claim_number TEST-CLM-0001 0.95 page 1
billed_amount 200.00 0.97 page 1
allowed_amount 150.00 0.84 page 1
plan_paid 120.00 0.93 page 1
adjustment_code C0-45 0.71 page 1
2 of 7 fields under 0.90:
allowed_amount on page 1, box [370, 260, 430, 276]
adjustment_code on page 1, box [580, 260, 620, 276]
The adjustment code shows why scores matter: "C0-45" has a zero where the letter O belongs in the CO group code, and the low score is the signal to check it. Which fields an EOB extraction returns, and why codes like that are hard to read, is covered in the guide to what EOB OCR reads.
Treat the values as strings until you validate them. Amounts arrive as text so that "150.00" does not become a float with rounding error; convert them with decimal.Decimal when you need arithmetic. Check the response against a schema before trusting its shape, as the guide to JSON Schema for medical documents explains. If the next stop is an EHR that speaks FHIR, the guide to converting a medical document to FHIR picks up from here.
The 0.90 threshold matches the default on the threshold demo with a fictional EOB, where Schema Care shows which fields would skip review and which would wait for a person. That demo is part of the Schema Care product, which reads documents into scored fields with a review queue but has no API to call from Python.
Python OCR API error handling: rate limits and failed jobs
Python OCR API error handling covers three cases: a 429 response when the script sends too many requests, a 5xx response when the server fails, and a job that the API accepted but could not finish. The first two deserve a wait and a retry. The third needs a person, because retrying the same file rarely changes the result.
A 429 status is defined in the IETF additional HTTP status codes, RFC 6585, as the code for a user who has sent too many requests in a given amount of time. The response may include a Retry-After header saying how long to wait. RFC 9110 defines the Retry-After header field as either a date or a number of seconds. The retry_delay function honours the seconds form and falls back to a doubling delay capped at 60 seconds when the header is absent or holds a date.
Server errors in the 5xx class mean the server knows it has failed, so the same request may work a moment later. The send function retries those up to five times. It also retries connection errors and timeouts, which requests raises as ConnectionError and Timeout. After the last attempt it lets the error stop the script instead of looping forever.
Other 4xx codes are not retried. A 401 means the key is wrong, a 413 means the file is too large, and a 415 means the server does not accept that file type. Sending the same request again will get the same answer, so the script stops and prints the error.
A failed job is different. The upload succeeded and the API tried, but the job status came back as failed, perhaps because the PDF was password protected or every page was blank. The script raises an error with the API's message. In a batch tool, log the file name and the message and move on to the next file, then hand the failures to someone who can look at the pages.
Log enough to debug without logging PHI. Once real documents flow through a script like this, extracted values are patient data, so log the job ID, the file name you assigned, the status code and the error message, not the field values. The example prints values to the terminal because it only ever sees fictional pages; a version for live documents should drop that print or send it somewhere access-controlled.
Two mistakes recur in scripts like this. Retrying an upload without an idempotency key, which can create duplicate jobs on a timeout. And treating a missing field as a confident blank: the example puts any field with no value into the review list, so an empty allowed amount gets a human look rather than passing silently.