Technical Step-by-Step Implementation Tutorials
Practical step-by-step guides with runnable source code to deploy real automation workflows in your company.
Automating PDF Invoice Extraction with Make and a Vision Model
From inbox to spreadsheet, with validation before saving: arithmetic, tax-number check digit and duplicate detection. Whatever fails goes to human review.
1Understand what you are actually processing
Before connecting anything, gather thirty real invoices you received last month and look at them. It is the step almost everyone skips, and the one that decides whether this works. Note: how many different suppliers there are, whether each uses its own layout, whether any are scans rather than native PDFs, and whether any run to several pages or several currencies. Five suppliers with stable layouts and this goes well. Forty suppliers, half of them crooked scans, and expect considerably more work — and possibly processing only the five largest, which are usually 80% of the volume.
2Get the invoices into one place
Create a dedicated address — say `invoices@` — and ask suppliers to send there. A forwarding rule handles the ones who keep emailing you personally. In Make, the trigger is the email module watching that mailbox, or Google Drive watching a folder. Prefer email: it removes the manual step of saving the attachment. Keep the original file before processing it. You will need it when something goes wrong, and record-keeping rules in Spain and Portugal require retaining the document, not only the extracted data.
3Extract the fields with a vision model
This is where a vision-capable language model does what rules cannot: read layouts it has never seen. But it only works if you demand a rigid format. Two precautions avoid most of the trouble. First, ask for JSON against a fixed schema and say explicitly what to do when a field is absent — without that, the model invents one. Second, set temperature to zero: you want the same answer for the same invoice, not creativity. The prompt below is deliberately restrictive. The temptation is to ask it to "extract the invoice data"; the result is inconsistent between runs.
{
"model": "gpt-4o",
"temperature": 0,
"response_format": { "type": "json_object" },
"messages": [
{
"role": "system",
"content": "Extrais dados de faturas. Devolves APENAS JSON com este esquema exacto. Quando um campo não existir no documento, devolve null — nunca inventes nem estimes. Valores numéricos sem símbolo de moeda, ponto como separador decimal. Datas em ISO 8601."
},
{
"role": "user",
"content": [
{ "type": "text", "text": "Esquema: {numero_fatura, data_emissao, data_vencimento, fornecedor_nome, fornecedor_nif, base_tributavel, taxa_iva, valor_iva, total, moeda}" },
{ "type": "image_url", "image_url": { "url": "data:application/pdf;base64,{{1.data}}" } }
]
}
]
}4Validate before saving
Never save what the model returns without checking. Three validations catch almost everything. **Does the arithmetic close?** Net plus VAT has to equal the total. If it does not, some field was misread. **Is the tax number valid?** Both Spanish and Portuguese numbers carry a computable check digit. Verifying it catches reading errors in seconds. **Has this invoice been processed already?** The number plus the supplier tax number form a unique key. Without this check, a supplier resending duplicates the entry. Whatever fails validation is not discarded: it goes to a separate sheet for human review. Automating is not deciding without oversight.
// Módulo de código no fluxo, entre a extracção e a gravação.
const f = JSON.parse(input.jsonDoModelo);
const erros = [];
// 1. A aritmética tem de fechar, com tolerância para arredondamento.
const somado = Number(f.base_tributavel) + Number(f.valor_iva);
if (Math.abs(somado - Number(f.total)) > 0.02) {
erros.push(`total nao bate: ${somado.toFixed(2)} vs ${f.total}`);
}
// 2. Dígito de controlo do NIF espanhol (8 dígitos + letra).
function nifEspanholValido(nif) {
const m = /^(\d{8})([A-Z])$/.exec((nif || '').toUpperCase().trim());
if (!m) return false;
return 'TRWAGMYFPDXBNJZSQVHLCKE'[Number(m[1]) % 23] === m[2];
}
// 3. Campos que nunca podem vir nulos.
for (const campo of ['numero_fatura', 'data_emissao', 'total']) {
if (f[campo] == null) erros.push(`campo em falta: ${campo}`);
}
return { valida: erros.length === 0, erros, dados: f };5Save, archive and alert
The destination depends on what you already use. A spreadsheet is enough to start and is easier to audit than it looks; an accounting system avoids re-keying later. Whichever it is, store the link to the original file and the date it was processed too. When someone asks in six months where a figure came from, that is what answers. And add failure alerting on day one. A flow that fails silently is worse than no flow: you carry on believing invoices are being processed after they have stopped.
6Measure whether it was worth it
After a month, compare against what you measured before starting: how many invoices arrived, how many passed without intervention, how many went to review, and how long review took. A flow handling 70% without intervention is already saving real time. One sending half to review is moving the work rather than reducing it — and in that case the problem is almost always suppliers with unstable layouts, worth handling separately. The model cost per invoice processed is small next to human time, but confirm it against your provider invoice rather than an estimate.