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.
01Two 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?
02Better input beats clever cleanup
OCR accuracy is decided mostly before the call. The engine has to separate characters from background, and everything that makes that harder costs you accuracy:
- Resolution. Text needs enough pixels per character. Body text roughly 20 to 30 pixels tall reads well; text 8 pixels tall is a guess. Downscaling a 4000-pixel phone photo to around 2000 pixels on the long side usually keeps text legible and fits under upload limits. Shrinking further than that is where accuracy drops.
- Contrast. Dark text on a light, even background is the easy case. Thermal receipts that have faded, grey text on a grey background, or a photo with a shadow across half the page are hard.
- Angle and curvature. A receipt photographed at 30 degrees, or a book page curving into the spine, distorts letter shapes. If you control the capture (a mobile app), guide the user to shoot flat and square.
- Compression. Heavy JPEG compression creates blocky artifacts around letters. If you are generating images for OCR yourself, use PNG.
- Format. The endpoint reads JPG, PNG and GIF. A HEIC photo from a phone has to be converted first, which is exactly what the previous lesson's converter does.
In a mobile capture flow, a cheap quality gate pays for itself: after OCR, if the text is very short or mostly symbols, ask the user to retake the photo while they are still holding the receipt, instead of discovering the problem in a batch job a day later.
03Scanned PDFs: no text layer, so render first
PDFs come in two kinds that look identical on screen. A PDF exported from a word processor or an invoicing system has a text layer: the characters are stored as characters, and extracting them is exact. A PDF produced by a scanner or a phone "scan" app is a stack of photographs; there is no text in it at all, only pixels.
Always try the exact route first. PDF to Text (pdftotext, a multipart upload in a field named pdf) reads the text layer and tells you whether there was one: hasText is false for a scanned document. Only then fall back to OCR, by rendering the pages to images with PDF to Image and running each through imagetotext.
pdftoimage accepts either an uploaded PDF or a public PDF URL in its pdf parameter, and returns one image per page:
curl -X POST "https://api.apiverve.com/v1/pdftoimage" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"pdf":"https://pdfobject.com/pdf/sample.pdf","pages":"1"}'images[0].downloadURL is a PNG of page 1, and images[0].width and height are its size at the default 150 dpi.
Every page image comes back with a downloadURL, which is already a public URL. You do not have to download it: pass it straight to imagetotext as url.
// Node 18+, ESM. Exact text if the PDF has it; OCR if it is a scan.
import { readFile } from 'node:fs/promises';
import { ocrUrl } from './ocr.mjs';
const KEY = process.env.APIVERVE_API_KEY;
async function call(api, init) {
const res = await fetch(`https://api.apiverve.com/v1/${api}`, {
method: 'POST',
headers: { 'x-api-key': KEY, ...(init.json ? { 'Content-Type': 'application/json' } : {}) },
body: init.json ? JSON.stringify(init.json) : init.form,
signal: AbortSignal.timeout(60_000),
});
const body = await res.json();
if (!res.ok || body.status !== 'ok') throw new Error(`${api} ${res.status}: ${body.error}`);
return body.data;
}
export async function pdfText(filePath, pages = '1') {
const bytes = await readFile(filePath);
const textForm = new FormData();
textForm.append('pdf', new Blob([bytes], { type: 'application/pdf' }), 'document.pdf');
const layer = await call('pdftotext', { form: textForm });
if (layer.hasText) return { source: 'text-layer', text: layer.text };
const imgForm = new FormData();
imgForm.append('pdf', new Blob([bytes], { type: 'application/pdf' }), 'document.pdf');
imgForm.append('pages', pages);
const rendered = await call('pdftoimage', { form: imgForm });
const texts = [];
for (const img of rendered.images) {
const { text } = await ocrUrl(img.downloadURL);
texts.push(text);
}
return { source: 'ocr', text: texts.join('\n\n') };
}Two cost notes. The text-layer check costs 10 credits and saves you a render plus an OCR call per page whenever it succeeds, which for system-generated invoices is nearly always. And the Free plan renders one page per call at 150 dpi; paid plans render up to 10 pages per call and can raise dpi to 300, which helps with small print. Keep source in what you store: downstream code should know whether a value was read exactly or guessed by OCR.
Why check pdftotext's hasText before rendering and OCRing a PDF?
04Cleaning 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.
05Measure 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.
06Privacy: 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.