Approva Manual
Approva — User Manual
Purchase requests and approvals you run yourself. Version 4.1.3 [src: app/controllers/api.php:16].
About this manual
Every statement here was written by reading Approva'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 Approva is
Somebody raises a purchase request; the right number of people approve it; a purchase order comes out the other end [src: app/index.php:101]. The value of the thing is that the approvals cannot be faked, and most of this manual is about how that is enforced [src: app/src/Purchase.php:370].
Where the chain ends is worth stating at the start, because the product states it to the requester in the decision e-mail: nothing has been ordered or paid, and Approva never places an order or pays a vendor [src: app/src/Purchase.php:302].
Roles
Three: requester, approver and admin [src: app/src/Purchase.php:30]. Approving is reserved to approvers and admins [src: app/src/Purchase.php:32], and routes are gated by role rather than by a permission map [src: app/src/Auth.php:39].
Segregation of duties
This is the control the product exists for, and the source says so in those words [src: app/src/Purchase.php:370].
Whether a person may record a decision on a request is answered by one function, which returns either the reason they may not or nothing at all [src: app/src/Purchase.php:389]. The reasons are checked in order, so the most specific one wins: not an approver, then your own request, then the delegation rules, then having already decided, then the request no longer being open [src: app/src/Purchase.php:370].
You cannot approve your own request. That check sits second, before anything about delegation, so no arrangement of delegations can route around it [src: app/src/Purchase.php:404].
You cannot decide twice. A person already recorded against a request is refused [src: app/src/Purchase.php:421].
The source is candid about why the function is written as a pure, isolated piece of logic: the guard was real and correctly placed, and had no test coverage at all — and a control nobody has tested is a control nobody can trust [src: app/src/Purchase.php:376].
Delegation, and the one-seat rule
An approver going away can delegate their seat for a window [src: app/src/Purchase.php:378].
Two rules keep the separation intact, and both are worth understanding because they are what stops delegation becoming a loophole.
A delegation is never usable on a request the delegator raised [src: app/src/Purchase.php:409]. Without that, an approver could raise a request and have their own delegate approve it — self-approval by proxy.
A delegation moves a seat; it does not add one. The delegator and the delegate can never both be counted on the same request [src: app/src/Purchase.php:414].
That second rule runs in both directions, and the source notes the asymmetry was not free. If somebody has already decided on your behalf while you were away, your own seat is spent and you are refused [src: app/src/Purchase.php:428]. The comment records that this was found by an adversarial pass — without it, a delegator could add a second approval after his own delegate had already been counted [src: app/src/Purchase.php:425].
Someone who is an approver in their own right decides as themselves; the delegation only comes into play where their own role would be refused [src: app/src/Purchase.php:406].
How many approvals a request needs
One, plus one more for every spending threshold the total crosses [src: app/src/Purchase.php:177].
Thresholds can be set per cost center rather than only company-wide [src: app/src/Purchase.php:206], and the reasoning is stated plainly: with a single global list you either drown one department in signatures or wave another through [src: app/src/Purchase.php:188].
Two things about that are worth knowing. A cost center with no rule of its own inherits the company list [src: app/src/Purchase.php:222], so an existing installation's requests keep exactly the number of signatures they had [src: app/src/Purchase.php:196]. And the routing changes only which threshold list is used — it is not a second approval mechanism, and the chain, the seats and the segregation rules are untouched by it [src: app/src/Purchase.php:192].
The number of thresholds is not fixed at two. The count is whatever you configure: one level to begin with, and one more for every configured threshold the total meets or exceeds [src: app/src/Purchase.php:15]. The setting is a free list rather than a pair of boxes [src: app/src/Database.php:212], duplicates are removed and the list is sorted before use [src: app/src/Purchase.php:167].
The rule is frozen at submission
When a request is submitted, the number of approvals it needs and a label for the rule that produced it are written onto the request [src: app/controllers/app.php:460], and reads prefer that stored value over recomputing one [src: app/controllers/app.php:26].
The source explains what this prevents, and it is worth understanding before you touch a threshold. Required levels used to be derived on every read, so editing a threshold silently re-decided every request ever raised: one approved under a one-signature rule turned back into "partially approved" the moment somebody tightened the rule, and the register stopped matching what had actually happened [src: app/src/Database.php:116].
So changing a threshold changes future requests. Requests already submitted keep the rule they were submitted under, and the label tells you which one that was [src: app/src/Purchase.php:287].
Requests raised before the freeze existed carry no locked value and keep deriving exactly as they did [src: app/src/Database.php:120].
Who signs each level: named steps
A cost center's rule can also say who signs each level. On Cost centers → Named steps, give each level a label and tick the approvers for it: level 1 "Marketing lead", level 2 "Finance" [src: app/controllers/v32.php:19]. Any one of the people ticked for a level fills that level's seat; a level with nobody ticked is open to every approver, exactly as before. Only active approvers and administrators can be named.
The steps are frozen onto the request when it is submitted, beside the threshold rule, so editing the cost center later never moves a request already waiting to other people [src: app/controllers/app.php:457]. The request record lists who signs each level and who has signed.
Only the people named for the next level can decide it; anyone else is told whose step it is. A named approver who is away hands their seat to a delegate in the usual way, and the delegate's decision is recorded on their behalf [src: app/src/Purchase.php:525]. Every other rule still applies: nobody signs their own request, and one person fills one seat. If nobody named can sign a level (everyone named has left, lost the approver role, raised this request, or already signed it), the level falls back to any approver, and the request says so rather than waiting forever [src: app/src/Purchase.php:487].
Copying a request (from 3.2)
Copy as a new request on any request's page makes a new draft of your own with the same vendor, cost center, justification and lines, under a new reference, to change and submit like any draft [src: app/controllers/v32.php:174]. Attachments, decisions, the budget note and any PO stay with the original, whose log records the copy.
Budgets
From 3.2 a cost center can carry a budget for a month, a quarter or a year (Cost centers → Budgets) [src: app/controllers/v32.php:173]. Committed spend is the total of the purchase orders issued against the center in the current window [src: app/src/Procure.php:65]. When a request is submitted, its budget position is written on it: the budget, what is committed and what it would be with this request, and "OVER BUDGET" when it would go over [src: app/controllers/app.php:465]; the record shows that and today's position, and the queue marks an over-budget request [src: app/src/Procure.php:81]. A budget is a flag for the approvers, never a block: the approval rules are unchanged.
Receiving and sending the PO
On an issued PO's page, record goods as they arrive, line by line; part deliveries are fine, and a line can never be received beyond what was ordered [src: app/src/Procure.php:136]. The receipt is checked again inside a locked transaction, so two people booking the same delivery cannot both count it [src: app/controllers/v32.php:234]. The PO shows not yet received, part received or received, and each receipt is on the request's log.
When Sending POs to vendors is switched on in Settings, an administrator can email the PO to the vendor's address on file, as plain text: the number, each line, the total and your reference, with "not an invoice" [src: app/src/Procure.php:159]. Every send is recorded on the PO, including refusals [src: app/controllers/v32.php:190]. (The PDF stays one click away for you; the shared mail transport sends text.)
Status
Approva stores two statuses and displays six [src: app/src/Purchase.php:38].
The stored pair is draft and submitted [src: app/src/Purchase.php:36]. Everything else — pending, partially approved, approved, rejected, purchase order issued — is worked out from the request, its approvals, whether a purchase order exists, and how many approvals were required [src: app/src/Purchase.php:350].
The order of that derivation matters: a draft is a draft; then any rejection makes it rejected; then an issued purchase order makes it issued; then enough approvals make it approved; then some approvals make it partially approved; otherwise it is pending [src: app/src/Purchase.php:350].
A decision can only be recorded while a request is pending or partially approved [src: app/src/Purchase.php:363]. Because status is derived rather than stored, a request cannot sit in the database claiming to be awaiting approval after it has been decided.
References and purchase order numbers
Requests carry a reference beginning PR- [src: app/src/Purchase.php:41] with a six-character body drawn from an alphabet without look-alike characters [src: app/src/Purchase.php:42].
Purchase order numbers are a zero-padded sequence [src: app/src/Purchase.php:46] — sequential rather than random, because a purchase order number is an accounting artifact and gaps in it are questions somebody has to answer.
Money
Money is held as integer cents throughout, and the class states that as its first rule [src: app/src/Purchase.php:9]. Line amounts are computed from quantity and unit price [src: app/src/Purchase.php:124] and totaled from the lines [src: app/src/Purchase.php:136], so no total is the result of floating-point arithmetic.
Delegation screens
Setting up a delegation requires being an approver or admin — a requester reaching it is refused with an explanation rather than shown an empty page [src: app/controllers/v3.php:537]. The same check guards creating one [src: app/controllers/v3.php:537].
The API and agent access
Machine access is a REST interface behind a key [src: app/index.php:94]. Requests can be listed, fetched and created; submitted, approved and rejected; a purchase order generated [src: app/index.php:101]; and orders, vendors and cost centers read [src: app/index.php:102].
Nine tools are exposed to an agent, of which five are read-only and four write [src: app/controllers/v3.php:277]: listing and fetching requests, a pending report, vendors and cost centers on the read side; creating, submitting, approving and rejecting on the write side.
The important sentence is in the instructions the agent is given: the key acts as its user, and the same approval guards apply [src: app/controllers/v3.php:275] — an agent cannot approve the requester's own request twice, or its own at all [src: app/controllers/v3.php:276].
That is the right design. An automation that could bypass segregation of duties would defeat the only control the product exists to provide.
Who is told
A submitted request mails the notifications inbox set in Settings. From 3.1.4, with "Tell the approvers" switched on (it ships off), each person who can sign the request is also asked at their own address, with the reference, who raised it, the vendor, the total, how many sign-offs the rule needs and a link to decide [src: app/controllers/v3.php:162]. Who counts is decided by the Approve button's own rule, so the requester, anyone who has already decided and anyone whose seat is spent are never asked [src: app/controllers/v3.php:143]. They sign in to decide; nothing is approved from the email.
From 3.2, with named steps, only the level's own people are asked, and as soon as a level is signed the next level's people are asked in turn [src: app/controllers/v3.php:74]; the message names their step [src: app/controllers/v3.php:196]. Each person can add their own chat channel (a Slack, Google Chat, Microsoft Teams or Discord incoming webhook) on their Security page, and the same notice goes there [src: app/controllers/v32.php:55]; only those four services' addresses are accepted [src: app/controllers/v3.php:222], and the shareable JSON backup leaves the address out.
The morning digest (Settings, off by default) emails each approver one list of every request waiting on them, with its age and their step, when your scheduler calls /cron/digest?t=<token> with the same token as /backup/run [src: app/controllers/v32.php:152]. Nobody gets an empty digest, and nobody gets two on one day [src: app/controllers/v32.php:125].
Backups
Both backup downloads — the JSON export and the raw SQLite file — are an administrator's; a signed-in requester or approver gets a 403 [src: app/controllers/api.php:333], and so do the restore page, a restore and minting the scheduled-backup token [src: app/controllers/v3.php:811]. The JSON export and the scheduled backup leave out the same list of credentials: password hashes, API-key hashes, webhook secrets, the SMTP password, the SSO client secret and every two-factor seed and recovery code [src: app/controllers/api.php:345].
Data requests: a vendor contact's address (4.1.1)
The one person outside your own team that Approva keeps anything about is the contact at a vendor: the address a purchase order is emailed to, which is often a named person's. An administrator opens Data requests (under Set up) and picks the vendor:
- Download their data writes one JSON file with the vendor row, every PO send to that address and every request-history line that names it.
- Erase the address replaces it with
[removed]on the vendor, in the PO send log and inside the request-history lines that say where a PO was emailed [src: app/controllers/v3.php:971].
The vendor, its requests and its purchase orders stay: who asked, who approved, what it cost and when are the approval record, and the vendor's name on them is your procurement record. Erasure is refused while a request for that vendor still awaits its decision or its purchase order, because that PO would be emailed to the address [src: app/controllers/v3.php:1012]. The audit trail records that an erase happened and how many rows it touched, never the address.
Kept: the audit trail's copy of each line saying a purchase order was emailed to this address, because the audit trail cannot be edited.
What Approva does not do
It does not decide whether a purchase is wise. It records who asked, enforces who may approve, counts the signatures the amount requires, and issues a number [src: app/src/Purchase.php:350]. Whether the thing should be bought is a judgment it neither makes nor stores an opinion about — no column on the request holds one [src: app/src/Database.php:219].
It does not order anything and it does not pay anybody. The source says so to the requester in plain words: nothing has been ordered or paid, and Approva never places an order or pays a vendor [src: app/src/Purchase.php:302]. There is no payments table in the schema [src: app/src/Database.php:199]. A purchase order comes out, and what happens next belongs to your finance system.
The delegation window is a date range, not a permission grant [src: app/src/Database.php:328]. A delegate acts within it under the rules above, and cannot use it on the delegator's own requests [src: app/src/Purchase.php:409] or where the delegator's seat is already spent [src: app/src/Purchase.php:414].
Looking after your install (from 4.0)
Updates from inside Approva
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; Approva 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. Approva is unavailable for about a minute while this runs. The update replaces Approva'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 Approva 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 Approva 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: vendors, cost centers and purchase requests in every approval state to try things on. You can remove it in one click. [src: app/install/index.php:151]. 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 vendors, cost centers and requests? 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 page (/restore, linked from Settings), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:70]. 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 Approva 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 Approva 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/approva/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)
Approva 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 Approva 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 Approva'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 Approva from the console starts a fresh session [src: app/index.php:52].
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 Approva'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 Approva in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:3]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Approva 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 Approva'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:6].
Switching a person off now ends their API access too. Their API keys are refused at once, even before anyone revokes them [src: app/src/Api.php:46], and switching them off on the People page revokes the keys and writes how many into the audit line (keys_revoked) [src: app/controllers/v3.php:490]. Switching them on again does not bring the old keys back; they make new ones.
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.