Every time a contract arrived, someone opened the PDF and typed the parties, the amount, and the term into the system by hand. When volume spiked, entry fell behind — and transcription introduced its own errors.
This is read-it-and-retype-it work. So reading was handed to OCR and extraction, checking was handed to rules, and people were left with only the exceptions.
The pipeline
- The uploaded contract is converted to text by OCR.
- Key fields — parties, amount, term — are extracted automatically.
- Missing required fields and malformed values are flagged by validation.
- Clean contracts are registered; only exceptions reach a person.
How to choose an OCR engine — the question we get most
An earlier version of this write-up listed only OCR in the stack. But the single thing readers actually want to know is which OCR. There isn't one answer; it splits along four axes.
- Accuracy in your language — support for non-Latin scripts varies enormously between engines. English benchmark figures do not transfer to Korean contracts. Measure it yourself on real sample documents.
- Table recognition — contracts routinely put amounts and dates inside tables. And **engines that handle table structure *and* non-Latin script well are rare** — a Korean contract is exactly that combination. The n8n community repeatedly reports that document-structure services outperform general-purpose OCR on table-heavy files.
- Cost structure — per page or per document, and is there a free tier? 100 documents a month and 10,000 a month lead to entirely different choices. Work out expected volume × unit price before you commit.
- Whether documents may leave your network — this is the first question corporate teams ask. If policy forbids sending contracts to an external API, the other three axes don't matter: your only candidates are local OCR plus a local model. Our on-premise case covers that arrangement.
Do scanned (image-only) PDFs work?
This is the second most common question. A PDF with a text layer and a PDF that is just scanned paper are completely different things. Plain PDF text-extraction nodes return nothing at all for the latter — an empty string, not an error. Left unhandled, that passes as "extraction succeeded, no content."
- Check which kind you have first. Open the PDF in a viewer: if you can drag-select the body text, it has a text layer.
- Image-only PDFs require a genuine OCR step. Text extraction alone will not do.
- The validation rules below are the safety net here. Empty extraction trips the required-field check and becomes
needs_review, so a blank contract never gets registered.
Never invent a value — the validation rules
Extraction is never 100%. So this workflow has one governing rule: if any required field is missing or malformed, don't register it — hand it to a person with the reason. A wrong amount on a contract is far worse than a missing one.
Required: party_a · party_b · amount · start_date · end_date
Amount parsing strip currency symbols, commas, spaces, then check it's numeric
"11,000,000 KRW" → 11000000
"to be agreed" → fails → needs_review
Date parsing 2026-01-31 / 2026.01.31 / 2026/01/31 / 20260131 all accepted
2026-02-31 → date does not exist → fails → needs_review
Cross-check end_date < start_date → needs_review2026-02-31 matters more than it appears. In most languages that silently rolls over to March 3 with no error. A contract end date lands two days late in the system and nobody notices. So this workflow formats the parsed date back to a string and compares it against the original.The extraction schema
CREATE TABLE IF NOT EXISTS contracts (
id bigserial PRIMARY KEY,
source_file text,
party_a text,
party_b text,
amount numeric,
start_date date,
end_date date,
auto_renew boolean NOT NULL DEFAULT false,
status text NOT NULL DEFAULT 'needs_review', -- registered|needs_review
review_reasons jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS contracts_status_idx ON contracts (status, created_at DESC);review_reasons preserves exactly why something was flagged. Whoever opens the record knows immediately what to check — and over time you can aggregate that column to see which field fails most often. That's your next improvement.
What you need
- n8n — uploads arrive from outside, so a publicly addressable URL is required.
- One Postgres database — a single contracts table.
- An OCR / document-extraction service — chosen on the four axes above. Start sample testing on something with a free tier.
- A Slack Bot User OAuth Token — scopes
chat:writeandchannels:read, for review notifications. - 20–30 real sample contracts — non-negotiable for measuring accuracy. Choose an engine without them and you'll be choosing again after rollout.
20260101), a missing amount, an amount reading as free text, a non-existent date (`2026-02-31`), an end date before the start date, and a missing party. Parsing "11,000,000 KRW" to the number 11000000 was confirmed. Node types and versions match our already-published workflows. Not verified: any specific OCR engine's accuracy on Korean text or tables — we did not measure it, so no accuracy figures for any engine appear in this document. The 12 min → 1 min and −90% figures come from the original engagement and were not re-measured with this workflow. Last verified: 2026-08-15.Setup (30 minutes, plus engine selection)
- Gather samples first — 20–30 real contracts. A mix of scans and text PDFs is better.
- Settle the data-egress question — confirm whether policy permits sending contracts to an external API. If not, your candidates narrow to local engines.
- Sample-test 2–3 engines — run the same documents through each free tier and count per-field hits. Keep that comparison in writing.
- Create the table — run the SQL above.
- Register the Postgres and Slack credentials — Test each, and
/invite @your-botin the Slack channel. - Import the workflow — n8n → Workflows →
...→ Import from File. - Fill in the OCR node — endpoint, auth, and request body for your chosen engine. Map its response to
party_a,party_b,amount,start_date,end_date. - Activate, then connect the upload path — point your upload form at the Production URL of
Contract Upload Webhook. - Test with a deliberately broken contract — upload one with the amount removed. It should be stored as
needs_reviewwith the reason posted to Slack. Testing only clean documents leaves half this workflow untested.
What happens in edge cases
- Poor scan quality — extracted values come back empty or garbled, trip required-field validation, and become
needs_review. No wrong value gets registered. - Handwritten signatures and stamps — this workflow makes no judgement about whether a contract is signed. If signature verification matters, keep a human review step.
- Several contracts in one PDF — only the first is extracted. Separate uploads are assumed; automatic splitting needs an extra stage.
- Non-PDF attachments — anything that isn't a PDF never enters this workflow. Convert at upload time, or add a node that reads that format first.
- A contract whose amount reads "to be agreed" — genuinely common. It fails format validation and becomes
needs_review. If those are frequent, drop `amount` from the required list and track it as its own status instead. The required-field list is at the top of theValidate Extracted Fieldsnode. - The OCR service being down — that execution ends in error. The upload already returned 200, so the user sees success. If you need a replay path, adopt the queue structure from our settlement and payment cases. The base structure has no queue.
Typing them in one by one feels like a story I made up. Now we just upload.— Administration team