Five processing tasks moved onto Start, Grow and Scale. OCR, Envelope OCR, Document Detection, Image Enhancement and Image Upscaling were each a separate plugin, and we have switched all five on across those three Filestack pricing and plans. Usage limits are unchanged: 5000 units included and updated $0.03/overage.
This is the second of five posts, one per task, covering what each one returns and where it belongs in an application. Five Processing Tasks Are Now Included on Start, Grow and Scale lists the set with the allowances.
OCR is the one that reads printed and handwritten text off an image and returns it with the coordinates of every block, line and word.
Here is an invoice, 1240 by 900 pixels:
On that file the ocr task returns 40 text areas and 48 lines, carrying the invoice number NGP-2026-04417, the PO number WCG-88213, the customer name Winterbourne Clinical Group, the line amount $2,214.00, the tax at $284.20 and the total $4,440.95. How to Pull Structured Data from Documents Using a Data Extraction SDK covers the stages either side of the extraction itself.
Key takeaways
- The
ocrtask returnsdocument.text_areas[].lines[].words[], and a flat top leveltextfield carrying every line, so plain text needs no tree walking. - We now include OCR on the Filestack Start, Grow and Scale plans at 5,000 units a month, alongside Envelope OCR, Document Detection, Image Enhancement and Image Upscaling.
- Every block, line and word carries a four point
bounding_boxin page pixels, which is what pairs a label such as TOTAL DUE with the amount printed to the right of it. - The response is
Cache-Control: privateand repeat requests are cache misses, so the task runs again on every call. - A file with no readable text returns HTTP 200 with an empty
text_areasarray rather than an error.
How to call the OCR API on an invoice
The task takes no parameters. It is a path segment in front of the handle:
https://cdn.filestackcontent.com/security=policy:POLICY,signature:SIGNATURE/ocr/HANDLE
With application security enabled, Intelligence tasks need a signed policy. An unsigned request returns a response naming the task that wanted one:
HTTP 403
security required for tasks: ocr
The policy is a base64 JSON object with an HMAC-SHA256 signature over it. It needs read and convert. One dependency, on Python 3.8 or later:
pip install requests
import base64, hashlib, hmac, json, os, time
import requests
APP_SECRET = os.environ["FILESTACK_APP_SECRET"]
handle = "YOUR_HANDLE"
policy = json.dumps({
"call": ["read", "convert"],
"expiry": int(time.time()) + 300,
"handle": handle,
}, separators=(",", ":"))
encoded = base64.urlsafe_b64encode(policy.encode()).decode()
signature = hmac.new(APP_SECRET.encode(), encoded.encode(), hashlib.sha256).hexdigest()
url = (
"https://cdn.filestackcontent.com/"
f"security=policy:{encoded},signature:{signature}"
f"/ocr/{handle}"
)
data = requests.get(url).json()
FILESTACK_APP_SECRET comes from the Security section of your application in the developer portal, and handle is the handle property of an already uploaded file. Signing runs server side, because the secret is what makes the signature mean anything.
Once the expiry timestamp passes, the same URL returns:
HTTP 403
application YOUR_APP_ID policy error: the signature has expired
A five minute expiry is fine for a call your own backend makes and then discards. A URL you hand to a browser needs enough life to survive the round trip.
What the OCR response contains
Three levels of nesting, and two flat fields that skip them.
{
"page_width": 1240,
"page_height": 900,
"text_area_percentage": 10.897939068100358,
"text": "Northgate Provisioning Co.\n4127 Delancey Row...",
"document": {
"text_areas": [
{
"bounding_box": [{"x": 836, "y": 756}, "... 4 points"],
"text": "TOTAL DUE",
"lines": [
{
"bounding_box": ["..."],
"text": "TOTAL DUE",
"words": [
{"bounding_box": ["..."], "text": "TOTAL"},
{"bounding_box": ["..."], "text": "DUE"}
]
}
]
}
]
}
}
Three things follow from that shape.
| Level | Carries | Use it for |
|---|---|---|
| top level | text, page_width, page_height, text_area_percentage |
plain text with no tree walking |
text_areas[] |
bounding_box, text, lines |
a block already concatenated, in the grouping the model chose |
lines[] and words[] |
bounding_box, text |
locating a value by where it sits on the page |
Every level below the top carries a four point bounding_box in page pixels. Text areas arrive in the grouping the model chose, not in reading order.
| Level | Fields |
|---|---|
| top level | document, page_height, page_width, text, text_area_percentage |
| text_area | bounding_box, lines, text |
| line | bounding_box, text, words |
| word | bounding_box, text |
page_width and page_height are the source dimensions, 1240 and 900 here, and every bounding_box is four {"x": ..., "y": ...} points in those pixels. text_area_percentage is documented as how much of the image is covered by text, and it came back as 10.9 on this invoice.
The full parameter list and response reference is under Optical Character Recognition.
How to get plain text out of the OCR response
The top level text field is every line, newline separated, in one string:
print(data["text"])
Northgate Provisioning Co.
4127 Delancey Row, Suite 210
Portland, OR 97219
accounts@northgateprov.example
BILL TO
Winterbourne Clinical Group
Blocks are available the same way. Each text_area carries its own text with the lines inside it already joined:
for area in data["document"]["text_areas"]:
print(repr(area["text"]))
'Northgate Provisioning Co.\n4127 Delancey Row, Suite 210\nPortland, OR 97219\naccounts@northgateprov.example'
'BILL TO'
'Winterbourne Clinical Group\n882 Kestrel Hollow Drive, Apartment 14C\nAsheville, NC 28806'
That covers full text search, indexing and passing a document to a language model. Reading one named value off a form needs the coordinates as well.
How to find an invoice total using bounding boxes
A text area is a block the task grouped, not a row of the document. On this invoice the totals column comes back as six separate areas, three labels and three amounts. The flat text runs the three labels together, then the three amounts:
Subtotal
Sales tax (7.0%)
Shipping
$4,060.00
$284.20
$96.75
TOTAL DUE
$4,440.95
The line after Subtotal is Sales tax (7.0%), so pairing a label to its value by line order gives the wrong answer on this layout. The coordinates give the right one. TOTAL DUE sits at x 836 to 952 and shares the y band 756 to 771 with $4,440.95 at x 1081 to 1174.
So the lookup is a label match, then the nearest area to its right whose vertical band contains the label’s centre:
def band(area):
xs = [p["x"] for p in area["bounding_box"]]
ys = [p["y"] for p in area["bounding_box"]]
return min(xs), max(xs), min(ys), max(ys)
def value_right_of(areas, label):
for area in areas:
if area["text"].strip() != label:
continue
_, label_right, top, bottom = band(area)
centre = (top + bottom) / 2
candidates = [
(band(other)[0], other["text"])
for other in areas
if band(other)[0] > label_right and band(other)[2] <= centre <= band(other)[3]
]
if candidates:
return min(candidates)[1]
return None
areas = data["document"]["text_areas"]
value_right_of(areas, "TOTAL DUE") # '$4,440.95'
value_right_of(areas, "Subtotal") # '$4,060.00'
value_right_of(areas, "Sales tax (7.0%)") # '$284.20'
value_right_of(areas, "PO number") # 'WCG-88213'
A label and its value are separate areas in each of those four cases. They are not always. On the same invoice the Issue date and Due date labels both sit to the left of one area holding both dates, '14 August 2026\n13 September 2026', so value_right_of returns that whole block for either label. Match on the label, then split the returned block when it carries more rows than the label does.
Nothing in this response says which block is a total and which is a street address. Envelopes are the case where we return the fields already named instead, and The Envelope OCR API Is Now Included on Start, Grow and Scale covers that task.
Does OCR work on a phone photograph of an invoice
Yes, and with no preprocessing in front of the call. An upload in a capture flow is often a phone photograph rather than a flat scan. Below is the same invoice under a perspective warp, a lighting falloff across the page, sensor noise and slight defocus. ocr returned 48 lines on it, carrying all six of the values listed above:
text_area_percentage came back as 5.74 on that photo against 10.9 on the flat render, because the page occupies less of the frame. The figure is measured against the whole image, so it moves with framing as well as with the amount of text.
Running doc_detection first, which deskews and crops the page out of a photograph, is a separate step with its own output rather than a prefix on this one. The Document Detection API Is Now Included on Start, Grow and Scale covers what that task produces and how it is chained.
Where to run OCR in an upload flow
ocr responds Cache-Control: private, and a repeat request to the identical URL came back x-cache: MISS rather than a cached copy. The task runs again on each request. A URL that renders extracted text into a page therefore runs the task on every page view.
Call it once, when the file arrives, and store the JSON. ocr is one of the Intelligence tasks available in Workflows. Attaching a workflow to the upload is the whole wiring:
npm install filestack-js
import * as filestack from 'filestack-js';
const client = filestack.init(YOUR_API_KEY);
client.picker({
storeTo: { workflows: ['YOUR_WORKFLOW_ID'] },
}).open();
YOUR_API_KEY is the API key from the developer portal, and YOUR_WORKFLOW_ID is the ID of a workflow created in its Workflows section, where you also name the task. That name is the key you read the result under. Results arrive on the fs.workflow webhook:
{
"id": "2abaa5e5-3e22-4f2e-bce5-2089a6a9a6b4",
"action": "fs.workflow",
"timestamp": 1788754883,
"text": {
"workflow": "2f370b1e-45f8-40c3-96a8-620cf3b67b57",
"jobid": "f4f5d926-f0c1-40eb-a816-e8bf8d99418b",
"sources": ["jKQtNddSkIEfFuC5tk9A"],
"results": {
"ocr_extract": {
"data": {
"document": { "text_areas": [] },
"page_height": 640,
"page_width": 1400,
"text": "Marisol Okonkwo-Reyes\n...",
"text_area_percentage": 13.838392857142857
}
}
},
"status": "Finished",
"ttl": 172800
}
}
results.ocr_extract is keyed by the task name set in the portal, ocr_extract in this run. Leave the name auto-generated and the key is ocr_1788754326647 instead. Everything under data matches the delivery-time response field for field, so value_right_of above runs on payload["text"]["results"]["ocr_extract"]["data"]["document"]["text_areas"] unchanged.
Two details on the receiver. A policy that triggers a workflow needs runWorkflow alongside convert. And the payload arrives with no FS-Signature or FS-Timestamp header until you create a webhook secret, which is a separate button next to the webhook row in the portal. Webhooks covers the verification once the secret exists.
Workflow logic branches on that output with dot paths and the operators lt, lte, gt, gte, eq, neq, incl, nincl, kex and knex, so a condition of data incl "INVOICE" sends invoices down one path and everything else down another.
What OCR returns when the file has no text
A photograph with nothing readable in it returns HTTP 200 and an empty document:
{"document": {"text_areas": []}, "text": "", "text_area_percentage": 0}
page_width and page_height are absent from that body, so code reading them directly raises a KeyError rather than seeing a zero. Branch on the array instead, with your own handlers for the two outcomes:
areas = data["document"]["text_areas"]
if not areas:
queue_for_manual_review(handle) # your code
else:
save_invoice(handle, value_right_of(areas, "TOTAL DUE")) # your code
An unreadable page and a page with no text land in the same branch, so route both to a person rather than writing an empty invoice row.
What invoice OCR costs per month
The included allowance is 5,000 units a month, and usage above that runs at $0.03 a unit.
Where the call sits decides how much of that a month of invoices consumes. Capturing once at upload and reading from your own table afterwards keeps consumption tied to how many invoices arrived. Leaving the call on the delivery path ties it to how much traffic those invoices attract.
Invoice and payables capture is the case this shape fits most directly, and The Benefits of Automating Invoices with OCR APIs covers what changes on the accounting side of it. Expense receipts, insurance claim intake and contract indexing all run the same three steps: upload, one call, one stored JSON blob keyed by handle. What changes between them is which label you look to the right of.
FAQ
Why does pairing a label with the next line give the wrong total?
Because text areas arrive in the grouping the model chose, not in reading order. On the totals column the three labels come back together, then the three amounts, so the line after Subtotal is Sales tax rather than its value. Match on coordinates instead.
Do I have to deskew a phone photograph first?
No. A warped, unevenly lit and slightly defocused photo of the same invoice returned 48 lines with every value intact. doc_detection is a separate task with its own output rather than a prefix on this one.
Why is my extracted text costing me units on every page view?
The response is Cache-Control: private and repeat requests miss the cache, so a URL that renders text into a page runs the task each time. Call it once when the file arrives and store the JSON against the handle.
How do I tell an unreadable page from a blank one?
You cannot, and both return HTTP 200 with an empty text_areas array. Branch on the array rather than the status code and route both outcomes to a person, since writing an empty invoice row is worse than either.
Joshua is a web developer with over 4 years of experience building responsive, high-performance websites and web applications. Currently working as an AI Automation Specialist, he combines modern web development with automation to create efficient, scalable digital solutions. He shares practical insights on WordPress, web development, and emerging technologies.
Read More →