Extracta Manual
Extracta — User Manual
Invoice and document data extraction you run yourself. Version 4.1.3 [src: app/controllers/api.php:64].
About this manual
Every statement here was written by reading Extracta's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.
Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.
What Extracta is
Upload a document, have a language model read the figures off it, then correct anything it got wrong and export the result. The route through the application is exactly that shape: upload [src: app/index.php:72], extract [src: app/index.php:74], review [src: app/index.php:75], export [src: app/index.php:78].
The part worth understanding first
A language model reads your invoice, and a language model will occasionally be wrong. Extracta is built around that fact rather than in spite of it, in three stages.
The model produces loose JSON. Whatever it returns comes back with the keys the model chose to use [src: app/src/Llm.php:196].
Deterministic code makes it canonical. That raw output is passed through a normalizer which pulls known fields out of the variants a model might emit, and converts every value to one fixed shape: money as integer cents, dates as ISO or null [src: app/src/Extraction.php:17].
Then the arithmetic is checked. A reconciliation step cross-checks the numbers against each other and reports what does not add up [src: app/src/Extraction.php:299].
The model is asked to read; it is not trusted to be consistent. Everything after the reading is ordinary code you can audit.
Which model reads your documents
You choose the provider, and you supply the credential. Three are supported [src: app/src/Llm.php:15]:
| Provider | Default model | Notes |
|---|---|---|
| OpenAI | gpt-4o-mini | Image sent as a data URL, JSON response format requested |
| Anthropic | claude-haiku-4-5-20251001 | Handles PDFs natively as well as images |
| Ollama | llama3.2-vision | Runs locally; no authentication required |
Under the model field, Settings offers a short list of model ids for the chosen provider, default first; you can still type any other id [src: app/src/Llm.php:58]. For messy or handwritten scans, the hint names Sonnet 5 (claude-sonnet-5), which is on the Anthropic list; the default stays claude-haiku-4-5-20251001.
The provider is read from your settings and an unknown one is refused rather than silently defaulted [src: app/src/Llm.php:201].
The defaults live in one list [src: app/src/Llm.php:47]. Anthropic retired the earlier default, claude-3-haiku-20240307, on 20 April 2026; an install that still has a retired Anthropic model saved uses the replacement Anthropic names for it, and Settings says so [src: app/src/Llm.php:102]. A model the provider refuses (retired, misspelt, or not pulled on your Ollama server) stops the extraction with a message that names the model and what to change [src: app/src/Llm.php:119].
Two limits apply to every call: a ninety-second timeout [src: app/src/Llm.php:32] and an output ceiling of 8,192 tokens [src: app/src/Llm.php:38]. That ceiling was raised from 2,048, and the source notes why raising it costs nothing — providers bill for the tokens actually produced, not the ceiling you allow [src: app/src/Llm.php:34].
The Ollama option is the one to notice if the documents matter: it runs against a model on your own machine, so the invoice never leaves it.
The canonical fields
Every document is reduced to the same set: vendor, invoice number, invoice date, due date, currency, subtotal, tax and total [src: app/src/Extraction.php:17]. Line items are kept alongside them.
Money is held as integer cents and dates as ISO Y-m-d or nothing at all [src: app/src/Extraction.php:17]. There is no third state where a date is "probably the fourth of March".
Money
The money parser handles what real documents contain: currency symbols and stray letters, the American 1,234.56 and the European 1.234,56, spaces used as thousands separators, negatives written either with a minus or in parentheses, and raw JSON numbers where the model returned one [src: app/src/Extraction.php:27].
Where there is no number at all it returns nothing rather than zero [src: app/src/Extraction.php:32]. A missing total and a total of zero are different facts and stay different.
Dates, and the 04/03 problem
Dates arrive in whatever form the document used. The normalizer accepts ISO dates, textual months like "Apr 3, 2026" or "3 April 2026", and numeric dates separated by slashes, dots or hyphens [src: app/src/Extraction.php:91].
Numeric dates are where the care shows. A four-digit leading number is treated as a year. For the ambiguous rest, the first number is the day if it exceeds twelve, the second is the day if it exceeds twelve, and only when both are twelve or under does the decision fall back to your day-first setting [src: app/src/Extraction.php:96]. Whatever comes out is validated as a real calendar date before it is accepted, and anything that fails becomes nothing rather than a guess [src: app/src/Extraction.php:96].
The day-first preference is a setting, so a European install can be told once that 04/03 means the fourth of March [src: app/controllers/app.php:612].
Since 3.3.0 a date written with its time ("2026-03-04T08:10:00Z", "2026-03-04 12:34:56"), as an agent or a model may send it, is read as the date it shows. The time and any time zone are dropped, never used to move the day [src: app/src/Extraction.php:101]. Before, such a value read as no date, so an agent's review that sent one blanked the invoice date. A time that is not a real time ("25:99") still makes it no date.
Currency
A three-letter code is derived from either a code or a symbol, covering the common ones, and returns empty where it cannot tell [src: app/src/Extraction.php:130].
The arithmetic check
After normalization the record is cross-checked, and this is the feature that earns its place [src: app/src/Extraction.php:265].
Two independent checks run: that the line items sum to the subtotal — or to the total, where there is no subtotal — and that subtotal plus tax equals the total [src: app/src/Extraction.php:299].
Three details make it useful rather than noisy. Each check is skipped when its inputs are absent, so a document with no line items does not generate a complaint about line items. A small tolerance is allowed for the rounding a model may introduce. And the warning names the figures, telling you what the lines sum to and what the subtotal says, rather than reporting that something is wrong [src: app/src/Extraction.php:299].
The result is a flag and a list of warnings, not a refusal. Extracta tells you where the arithmetic disagrees and leaves the judgment to you.
Uploading, one document or fifty
The upload screen takes one file or a batch of up to 50 in one go, each a PNG, JPEG, WebP or PDF of up to 10 MB [src: app/controllers/app.php:109]; the type is read from the file's bytes, not from its name. A single file opens its review screen, as before. A batch lands in the queue as To extract, and Extract waiting documents on the documents list reads them through your model, ten per click and oldest first, so a slow model never runs a web request past your host's time limit [src: app/controllers/app.php:293]. The Extract button, the batch and the email inbox all use one extraction path [src: app/controllers/app.php:209].
On a phone, Take a photo of a receipt or invoice opens the camera; take one photo per document, as many as you like, then upload them together.
One file, several documents. A batch scanned into one PDF is read as what it is: when the model finds more than one invoice or receipt in a file, it returns them separately and each becomes a row of its own, named "document 2 of 3" and so on, all pointing at the same stored file [src: app/src/Extraction.php:195]. Every document is also classified as an invoice, a receipt, a credit note or other [src: app/controllers/app.php:256].
How sure each field is. Every field's reading gets a confidence from the value as printed, computed without asking the model [src: app/src/Extraction.php:225]: a clean number is sure, a number found inside other words is less so, a date like 04/03 that reads either way is flagged, a missing total or date is flagged. The review screen marks a field read with less certainty, and the documents list says "check a field". Once a person reviews the document, the doubt is cleared: every field has been checked.
E-invoices: read from their XML (3.3.0)
An e-invoice already holds its figures as data, and for a hybrid PDF the embedded XML is the invoice, not the picture. So Extracta reads e-invoices from their XML, with no model call and no key: UBL 2.1 invoices and credit notes (Peppol BIS, XRechnung UBL), and UN/CEFACT CII (ZUGFeRD 2.x, Factur-X, XRechnung CII), whether uploaded as an .xml file or embedded in a ZUGFeRD / Factur-X PDF [src: app/src/EInvoice.php:25]. Seller, number, dates, currency, net, VAT, gross and the lines map to the usual fields [src: app/src/EInvoice.php:70], so review, vendor rules, exports and the API treat them like any other document, each field at full confidence. The review page says so, and also shows the seller's VAT ID, the buyer reference, the account to pay and the SHA-256 of the file. The file itself is kept exactly as received and downloaded, never displayed, in the browser [src: app/controllers/app.php:263]. Anything that is not an e-invoice goes to your model as before, and a file with a DOCTYPE is refused, since e-invoices carry none.
An XRechnung has no picture at all, and in a ZUGFeRD or Factur-X PDF the XML prevails over the page, so the review page's viewer shows what the XML says, laid out for a person: who it is from and to, with the VAT ID and buyer reference, the dates, the lines, the VAT per rate, the totals, the amount due and the account to pay (below the PDF's own entry for a hybrid PDF) [src: app/views/partials/einvoice_view.php:1]. It is read afresh from the stored file each time, never from the fields you corrected, so the file and your working copy can be compared side by side [src: app/controllers/app.php:378].
For VAT bookkeeping, what an e-invoice's XML says beyond the usual fields travels with it: the CSV export gains three columns at the end (seller VAT ID, buyer reference, account to pay) when the file holds any e-invoice, and the JSON export and the API carry an einvoice block with those, the VAT breakdown per rate and category, and the file's SHA-256. A file of photographed invoices only is exactly as before.
Extracta reads the files you give it: upload, the email inbox or the client drop-box. It does not connect to Peppol, Chorus Pro, KSeF, SdI or any other platform, and it does not report anything to a tax authority. Where invoices arrive through such a platform, download them there and bring them in.
Recipient status: what you reported (3.3.0)
Some countries have the recipient of an e-invoice report what became of it: Croatia since 2026 (rejections, among others), Slovakia from 2027, and Spain once its B2B scheme starts (acceptance, rejection and payment). That reporting goes through your platform or provider, never through Extracta. What Extracta adds is the record beside the invoice: on the review page of an extracted or reviewed document, the Recipient status card takes the status you reported (accepted, rejected or paid in full), the date you reported it, the platform's reference and a note [src: app/views/review.php:86]. A rejection takes its reason in the note, since that is what you reported, and a date that is not a real date is refused [src: app/controllers/app.php:1003]. Choosing "—" clears the record. Anyone whose role can review documents can record it, and each change goes in the audit log.
The status travels with the document: the JSON export and the API carry a recipient_status block (status, date reported, reference, note) [src: app/src/Api.php:100], the MCP export_rows tool carries it as _recipient_status, and the CSV export gains three columns at the end (status, date reported, platform reference) when any document in it has a status recorded [src: app/src/Extraction.php:387]. A file with none recorded is exactly as before.
The email inbox
Forward invoices to a mailbox of your own and they arrive as documents. The inbox is off until you tick it in Settings → Email inbox and give it an IMAP server, a user name and a password (SSL/TLS on 993, STARTTLS on 143, or none for a server on the same machine). It reads the unread messages in the folder you name, keeps every PDF or image attachment as a document marked "by email" with the sender's address, ignores anything else, and marks each message it read as read [src: app/src/Inbox.php:241] — so give it a mailbox that receives only invoices. It reads up to 20 messages per run [src: app/src/Inbox.php:26].
Run it from cron with php bin/fetch-inbox.php (every 15 minutes is plenty), or press Check inbox now on Settings; Test the inbox connection logs in and counts the unread messages without reading them. With Extract each mailed document on arrival ticked, each one goes through your model straight away; otherwise it waits in the queue [src: app/controllers/app.php:684]. The last run's result shows on Settings. No PHP extension is needed. The password is kept like the SMTP password, and the shareable JSON backup leaves it out.
The client drop-box (3.2.0)
Settings → Client drop-box makes a private link where clients upload invoices, receipts and statements without an account: PDFs or photos, up to 10 at a time, with an optional name or email [src: app/controllers/app.php:895]. The files arrive in Documents marked via drop-box, waiting for you to extract them; nothing is read or extracted on arrival, and the page never shows anything already sent. It is off until you switch it on, and off on the public demo. The link is compared in constant time and can be replaced at any moment (the old one then says "not found") [src: app/controllers/app.php:878]. Everyone together can send 60 files an hour, and a hidden field turns away form-filling robots. The link is left out of the shareable JSON backup like other credentials: after a restore, make a new link.
Reviewing
Extraction requires the review permission, as does the review screen itself [src: app/index.php:74]. Correcting a value and saving it is what moves a document out of the queue [src: app/index.php:76].
A possible duplicate is flagged on the review screen [src: app/controllers/app.php:343]: the same vendor (letters and digits, any case) with the same invoice number, or with the same total on the same invoice date, as a document already in Extracta [src: app/src/Extraction.php:279]. The warning links to the other document; nothing is merged or deleted, and the decision stays yours.
Vendor rules: invoices that arrive coded
The review screen has an Account field: a code such as 6100 or a name such as Office supplies. Set it and save, and the vendor becomes a rule, so that vendor's next invoice arrives with the account already filled in [src: app/src/VendorRules.php:96]. The rule is the vendor's name without its legal form ("Metro Office Supplies Ltd" teaches METRO OFFICE SUPPLIES) [src: app/src/VendorRules.php:46], matched as whole words, and the longest matching rule wins, so AMAZON WEB SERVICES beats AMAZON [src: app/src/VendorRules.php:67].
A rule never overwrites an account a person set [src: app/src/VendorRules.php:82]. A rule fills an empty account or replaces one another rule set; it leaves alone an account you typed, on re-extraction and on Apply rules to all documents alike. The Vendor rules page lists every rule, how many documents it coded, and whether it was learned or added by hand; an administrator can add, apply and delete them. Deleting a rule leaves the accounts it already set.
The account is not an extracted field. It is never sent to the model and never stored among the extracted values, whose whitelist is unchanged.
Exporting
Exports come out as CSV or JSON [src: app/index.php:78].
The CSV is written properly rather than by joining strings with commas: a cell is quoted when it contains a comma, a quote, a carriage return or a line feed, inner quotes are doubled, and lines end with CRLF as the format requires [src: app/src/Extraction.php:327]. Money is written as decimals in the export even though it is held as cents internally [src: app/src/Extraction.php:340].
There are also named presets whose column sets match documented import formats, so the file can go straight into the ledger that expects it [src: app/src/Extraction.php:346]. Each document's account fills the QuickBooks Account and Xero *AccountCode columns; the plain CSV carries it as its last column, Account [src: app/src/Extraction.php:380], and the JSON as account.
Users, roles and what each may do
Three roles [src: app/controllers/v3.php:44].
| Permission | viewer | member | admin |
|---|---|---|---|
document.view | yes | yes | yes |
template.view | yes | yes | yes |
audit.view | yes | yes | yes |
document.upload | — | yes | yes |
document.review | — | yes | yes |
view.save | — | yes | yes |
| everything else | — | — | yes |
The source describes the intent directly: a viewer is a finance seat that reads the register without touching the numbers, a member is the reviewer who corrects extracted values and clears the queue, and templates, the language-model credential and the register's erasure powers are admin only [src: app/controllers/v3.php:44].
Settings
One form holds the company name, the currency, the language-model provider, model, API key and base URL, the day-first date preference and the timezone [src: app/controllers/app.php:612]. A second holds the mail configuration [src: app/controllers/app.php:629].
One behavior is worth knowing because it prevents a common accident: leaving the SMTP password blank keeps the stored one rather than clearing it, so saving the settings form without retyping the password does not break working mail [src: app/controllers/app.php:639].
The API and agent access
Machine access uses a key [src: app/index.php:92]. Documents can be listed and fetched, a review can be submitted, and the export can be pulled [src: app/index.php:93].
Five tools are exposed to an agent, and unlike some products in this range two of them write [src: app/controllers/v3.php:44]:
| Tool | Writes? | What it does |
|---|---|---|
list_documents | no | Documents with status and whether they have been reviewed |
document_detail | no | One document with its extracted fields and the arithmetic check |
export_rows | no | The extracted rows as structured data, ready for a ledger |
submit_document | yes | Records extracted values for a document |
review_decide | yes | Approves a document as reviewed, or sends it back |
The two writers are the review workflow, not the extraction: an agent can record values and clear the queue, and the values it submits go through the same normalization as everything else [src: app/controllers/v3.php:44].
The public demo never calls a real model
Where the application is running as a demonstration, extraction does not reach any provider. A fixture is returned instead, flagged as simulated and carrying a note saying so, and the demo allows only a few extractions per session [src: app/controllers/app.php:213].
That matters for two reasons: a public demo cannot spend somebody's API budget, and nothing anyone uploads to a demo is sent to a third party. The demo's upload page says so before the upload, not only after it.
What Extracta does not do
It does not guarantee the figures are right. A language model reads the document, and the checks that follow test the record only against itself — whether the lines sum to the subtotal, and whether subtotal plus tax equals the total [src: app/src/Extraction.php:299]. Nothing compares any figure with the document it came from. A plausible wrong number that happens to be arithmetically consistent passes both checks, which is why extraction lands in a review queue rather than an export [src: app/index.php:75].
It does not decide anything about your accounts. It extracts, checks arithmetic and exports; what the numbers mean is yours, and the account codes it fills in come only from vendor rules you set or taught it by correcting a document.
Subject search and redact: what it removes and what it keeps (4.1.1)
Extracta has no person table: a person appears as a value on a document. An admin opens Subject search, types a name or address, and presses Redact on each document that mentions it. Redact blanks the matching values, the raw extraction, the sender and the file name of that document, on extracted documents and on drop-box uploads not yet extracted; the amounts and the dates stay.
Kept: the source document itself, the file as it was uploaded or mailed in, because it is the evidence of the transaction.
Looking after your install (from 4.0)
Updates from inside Extracta
Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Extracta never checks by itself.
When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Extracta is unavailable for about a minute while this runs. The update replaces Extracta's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Extracta shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Extracta is hosted for you, the host updates it and the page says so.
Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.
Starting with sample data
The installer offers Start with sample data: three sample documents with their fields already extracted to try things on. You can remove it in one click. [src: app/install/index.php:140]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.
When you are done with it, press the remove button in the sample banner. It asks "Remove the sample documents? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].
Backups by themselves, and a copy somewhere else
On the Backup & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:78]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.
To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Extracta reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].
On Nginx: keep data/ private
On Apache, the .htaccess that ships with Extracta keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.
A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):
server {
listen 80;
server_name example.com;
root /var/www/extracta/app;
index index.php;
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
location = /install/ { rewrite ^ /install/index.php last; }
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
The Health page
Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].
Connect it to other tools (Zapier, Make, n8n)
Extracta sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Extracta on the store shows the same file [src: app/API.md].
New in 4.1.2: sign-in limits
New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. On Ownware Cloud, opening the app from the console starts a fresh session.
Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].
Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.
If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Extracta's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.
On Ownware Cloud, opening Extracta from the console starts a fresh session [src: app/index.php:57].
The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:21].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Extracta's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.
No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Extracta in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Extracta framed inside another site of your own, that frame now stays empty; link to it instead.
/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Extracta's own address [src: app/src/Mcp.php:272].
Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.
The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].
Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].
Factur-X PDFs. The invoice data embedded in a PDF is unpacked with a 10 MB limit. Before 4.1.3 a small PDF built to expand to hundreds of megabytes could stop the request by running out of memory; now such a file is simply not read as an e-invoice [src: app/src/EInvoice.php:46].
Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.