Here is real output from an OCR call on a screenshot of a blog post:
{
"text": "A year after that (in 2021) I hired somebody tp#lp me write blog posts for\nmy personal website.\n\nThe point is, | like reinvesting the money | make\nback into my business."
}
Most of it is right. "tp#lp" was "to help", where a cursor or a smudge sat over the letters. And twice the word "I" came back as a vertical bar, because in that font they are nearly the same shape. Nothing in the response tells you which words are wrong.
That is OCR in one paragraph: fast, cheap, mostly correct, and wrong in ways that look plausible. The engineering is in getting good input to it, cleaning its output, and designing your product so that "mostly correct" is acceptable or caught.
Two ways to send an image
Image to Text (imagetotext, 10 credits) has two input styles on the same URL:
- Upload a JPG, PNG or GIF as
multipart/form-datain a field namedimage, up to 5 MB. - Point at it with a JSON body
{"url": "https://..."}, when the image is already reachable on the public internet.
The response has text on every plan. words, characters and lines are premium; count them yourself from text if your plan does not include them.
If the image contains no recognisable text at all, the API returns 404 with "could not find any text". That is a result, not a fault. A blank photo, a picture of a cat, a page that is entirely an illustration: all legitimate inputs with no text in them. Do not retry a 404 and do not page anyone about it. Map it to an empty string and move on.
// Node 18+, ESM
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const KEY = process.env.APIVERVE_API_KEY;
const URL_ = 'https://api.apiverve.com/v1/imagetotext';
async function unwrap(res) {
const body = await res.json();
if (res.status === 404) return { text: '' }; // no text found: a valid answer
if (!res.ok || body.status !== 'ok') throw new Error(`ocr failed (${res.status}): ${body.error}`);
return body.data;
}
export async function ocrFile(filePath) {
const form = new FormData();
form.append('image', new Blob([await readFile(filePath)]), path.basename(filePath));
const res = await fetch(URL_, {
method: 'POST',
headers: { 'x-api-key': KEY },
body: form,
signal: AbortSignal.timeout(30_000),
});
return unwrap(res);
}
export async function ocrUrl(imageUrl) {
const res = await fetch(URL_, {
method: 'POST',
headers: { 'x-api-key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ url: imageUrl }),
signal: AbortSignal.timeout(30_000),
});
return unwrap(res);
}import os
import requests
KEY = os.environ["APIVERVE_API_KEY"]
URL = "https://api.apiverve.com/v1/imagetotext"
def _unwrap(res):
if res.status_code == 404:
return {"text": ""} # no text found: a valid answer
body = res.json()
if not res.ok or body.get("status") != "ok":
raise RuntimeError(f"ocr failed ({res.status_code}): {body.get('error')}")
return body["data"]
def ocr_file(file_path):
with open(file_path, "rb") as f:
res = requests.post(URL, headers={"x-api-key": KEY},
files={"image": (os.path.basename(file_path), f)}, timeout=30)
return _unwrap(res)
def ocr_url(image_url):
res = requests.post(URL, headers={"x-api-key": KEY}, json={"url": image_url}, timeout=30)
return _unwrap(res)Which style to use depends on where the bytes are. Files a user just uploaded to your server are already in memory; upload them. Images already sitting at a public or signed URL (your bucket, another API's downloadURL) can be passed by reference, which saves you downloading and re-uploading them. The URL must be reachable from the open internet: localhost, private network addresses and pages behind a login will fail.
Your OCR worker sees a 404 from imagetotext on about 3% of uploads and retries each one five times before alerting. What should it do instead?
Answer the check above to continue.
Cleaning OCR output without making it worse
Raw OCR text needs a pass before you use it, and the pass must be conservative. The goal is to fix things that are unambiguous and leave everything else for a human or for validation.
Safe, mechanical fixes:
- Normalise line endings and collapse runs of spaces; trim each line.
- Rejoin words hyphenated across a line break (
exam-\nplebecomesexample), but only when both halves are lowercase letters. - Replace a lone
|between spaces withIin prose, as in the example at the top. Not in tables, where a pipe may be a real column separator.
Unsafe fixes, which you should not automate:
- Never "correct" digits. OCR confuses
0/O,1/l/Iand5/S. Changing anOto a0inside an invoice number looks helpful until it is a letter. For numeric fields, parse with a strict pattern and reject what does not match, rather than repairing it. - Do not spell-check names, codes or addresses. A dictionary will happily turn a surname into a common word.
import re
def clean_ocr(text):
text = text.replace("\r\n", "\n")
text = re.sub(r"([a-z])-\n([a-z])", r"\1\2", text) # rejoin hyphenated words
text = re.sub(r"(?<= )\|(?= )", "I", text) # lone pipe in prose -> I
lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in text.split("\n")]
return "\n".join(lines).strip()
# US-style amounts: 12.00, 1,284.50. Adjust for locales that use a decimal comma.
MONEY = re.compile(r"(?i)\btotal\b[^0-9\n]{0,15}(\d{1,3}(?:,\d{3})*\.\d{2})\b")
def find_total(text):
"""Return the total as a string like '1284.50', or None. Never guesses."""
m = MONEY.search(text)
return m.group(1).replace(",", "") if m else NoneThen validate structurally. On a receipt, the line items should sum to the subtotal, and subtotal plus tax should equal the total, within a cent. When the arithmetic agrees, the numbers were almost certainly read correctly. When it does not, one of them is wrong and you cannot tell which: route the receipt to review and show the person the image next to the extracted values. That single check catches far more OCR errors than any amount of character-level cleanup.
Measure accuracy on your own documents
Accuracy figures in the abstract are meaningless. What matters is how often OCR is right on your inputs: your customers' receipts, your scanner, your fonts. You can measure that cheaply by generating test images whose text you already know, which is a good use of the previous lesson's renderer.
curl -X POST "https://api.apiverve.com/v1/htmltoimage" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<div style='padding:40px;font:28px monospace'>INV-20931 TOTAL 1,284.50 DUE 2026-11-30</div>","width":800,"height":200}'Pass the downloadURL to imagetotext as url and compare its text with the string you rendered.
Render a few dozen strings in the fonts and sizes you see in production, OCR each one, and compute the character error rate: the edit distance between expected and actual text, divided by the expected length. Then do the same for twenty real documents that someone has transcribed by hand. The rendered set tells you how the engine behaves on clean input; the real set tells you how much your capture conditions cost.
Keep that harness. OCR engines are improved over time, and output for the same image can change: the screenshot at the top of this lesson once came back with "Ayear" and now returns "A year". Tests that compare exact OCR strings will break on improvements. Test against an error-rate threshold instead.
Privacy: OCR turns images into searchable personal data
A photo of a receipt is an image. The same receipt as text is a record containing a name, a partial card number, a store location and a time, and it is now indexable, searchable and joinable with everything else in your database. That change matters.
- Extract what you need and discard the rest. If you need the total and the date, store those, not the full text.
- Mask card numbers, national ID numbers and similar values before storage, even when they are already partially masked on the receipt.
- Apply a retention period to raw OCR text and to the source images, and honour deletion requests for both.
- Tell users in your privacy notice that uploaded documents are processed to extract text, and by whom.
Your expense app OCRs receipts and stores the full text so users can search it later. A security review flags it. What is the most proportionate change?
You can now read text out of any image or document that reaches you. The last lesson in this course looks at what else is in an image: captions, faces and content that should never be shown, and how to screen user uploads responsibly: Analyze and moderate images.