Invora Manual
Invora — User Manual
Invoicing and estimates you own outright. Version 4.1.3 [src: app/controllers/api.php:354].
About this manual
Every statement in this manual was written by reading Invora's own source code, and every one of them 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 here disagrees with the application, the application is right and this manual has a bug — the markers are there so you can check.
Nothing is described that the code does not do. Where a feature is off until you switch it on, or is deliberately limited, that is said plainly rather than omitted.
What Invora is, and what runs it
Invora is a single-tenant invoicing application: one business per installation, with its own database and its own users [src: app/src/Auth.php:2].
It runs on PHP with no Composer dependencies and no build step — the PDF writer, for instance, is written from scratch precisely so that nothing has to be installed alongside it [src: app/src/Pdf.php:2]. Data can live in either SQLite or MySQL; you choose during installation [src: app/install/index.php].
The whole application is served through one front controller, which loads the modules, boots the database, applies any pending schema migrations, and then dispatches your request to a route [src: app/index.php:50].
Schema migrations run on every request and are written to be safe to repeat, so upgrading is a matter of replacing files — there is no separate migration command to remember [src: app/index.php:60].
Installing
Point a web server at the app directory. If config.php is absent, Invora redirects you to the installer rather than showing an error [src: app/index.php:44]. If the file exists but the tables do not, you are sent to the installer as well [src: app/index.php:59].
The installer creates the first administrator [src: app/src/Auth.php:2]. Keep those details safe: see Users, roles and what each may do for why the last remaining administrator cannot be demoted or switched off.
Signing in
Password
Sign in at /login [src: app/index.php:72]. Invora looks up the account by e-mail address, lower-cased and trimmed, and requires the account to be active; a deactivated account cannot sign in at all [src: app/src/Auth.php:9]. On success the session identifier is regenerated, which is the standard protection against session fixation [src: app/src/Auth.php:11].
Repeated failures are rate-limited [src: app/src/LoginRate.php].
Two-step verification
Two-step verification is optional and set up from the /security page [src: app/index.php:113]. It uses standard time-based one-time passwords — SHA1, six digits, a thirty-second period — so any ordinary authenticator application will work [src: app/src/Totp.php:29]. A small amount of clock drift is tolerated [src: app/src/Totp.php:8].
Enrolling produces ten recovery codes, shown once and never again [src: app/src/Totp.php:56]. Only hashes of those codes are stored, which is why they cannot be re-displayed later [src: app/src/Totp.php:12]. Print them or put them in a password manager before you leave the page. A recovery code is consumed when it is used [src: app/src/Totp.php:67].
Once enrolled, signing in adds a second step at /login/2fa [src: app/index.php:111]. You can turn it off again from the same security page [src: app/index.php:115].
Single sign-on
Invora can delegate sign-in to an OpenID Connect provider. Begin at /login/sso, and the provider returns the visitor to /login/sso/callback [src: app/controllers/v3.php:1135]. The provider details — issuer, client identifier, client secret, and an enable switch — are saved by an administrator from the settings page [src: app/views/settings.php].
Finding your way around
The navigation is defined in the shared layout, and the labels there are the ones this manual uses [src: app/views/layout.php:24]. Note in particular that the page for products and tax rates is labeled Items & Tax, even though its address is /catalog [src: app/views/catalog.php].
Each navigation entry carries a single-key access shortcut [src: app/views/layout.php:24].
Clients
Clients are listed at /clients, created at /clients/new, and edited at /clients/{id}/edit [src: app/index.php:94]. Deleting a client is a separate action, open to clerks as well as administrators, and it is refused while the client still has documents [src: app/controllers/app.php:503].
Two data-protection actions sit on each client record and are described under Data-protection tools.
Invoices and estimates
Invora keeps one kind of document with two faces. Invoices are listed at /invoices and estimates at /estimates, and both lists are produced by the same underlying function [src: app/controllers/app.php:220].
Create an invoice at /invoices/new or an estimate at /estimates/new; edit either at /documents/{id}/edit; both save through /documents/save [src: app/index.php:78].
Statuses
A document carries one of these statuses: draft, sent, paid, partial, overdue, accepted, or declined [src: app/controllers/app.php]. The last two belong to estimates. Set a status from the document itself [src: app/index.php:82], or mark many documents as sent at once with the bulk action [src: app/controllers/v3.php:1111].
Turning an estimate into an invoice
An accepted estimate can be converted rather than retyped [src: app/index.php:85].
Discounts
A document-level discount can be expressed either as a percentage or as a fixed amount [src: app/src/Money.php].
Money
Every figure on a document is produced by Invora's money functions, which are tested against a separately verified reference implementation [src: app/tests/run.php:3]. The PDF writer formats those numbers and never recalculates them — a deliberate rule, so that a printed document and the screen can never disagree [src: app/src/Pdf.php:12].
Attachments
Files can be attached to a document, served back from /files/{id}, and deleted [src: app/controllers/v3.php:1110].
Saved views
A filtered list can be saved and later removed [src: app/controllers/v3.php:1114].
Payments
Record a payment against a document at /documents/{id}/payments [src: app/index.php:87]. Removing a payment is restricted to administrators [src: app/controllers/app.php:519].
Recording a payment fires the payment.recorded webhook, and if the payment settles the document, invoice.paid as well [src: app/controllers/app.php:413].
Items & Tax
Reusable line items and tax rates live on the Items & Tax page at /catalog [src: app/views/catalog.php]. Saving and deleting items, and saving and deleting tax rates, are all administrator-only [src: app/index.php:96].
Deleting a tax rate does not delete the items that used it — those items have their tax cleared instead, so nothing silently disappears [src: app/controllers/app.php:519].
Sending a document to a client
The public link
Every document is created with its own unguessable public token [src: app/controllers/app.php:315]. That token gives a client a read-only page at /p/{token}, with printable and PDF versions at /p/{token}/print and /p/{token}/pdf [src: app/index.php:91]. Nobody needs an account to open it.
Sending by e-mail
Client-facing e-mail is off by default and must be switched on by an administrator [src: app/controllers/app.php:589]. Once on, a document can be sent from /documents/{id}/send [src: app/controllers/v3.php:1126], and the message contains the public link described above [src: app/src/ClientMail.php:77].
Sending requires your own SMTP details, entered in settings — Invora does not relay through anybody else's service.
A pay-online link
You can display a link to whatever payment page you already use. It is stored with its scheme checked first, because it is rendered on a page your client opens [src: app/controllers/app.php:586]. Its wording is yours to set and defaults to Pay this invoice online [src: app/controllers/app.php:590].
PDFs
Any document can be downloaded as a PDF from /documents/{id}/pdf [src: app/controllers/v3.php:1138]. Files are A4 and use the core Helvetica fonts rather than embedding anything, which keeps them small [src: app/src/Pdf.php:5].
Recurring invoices
Set a cadence on an invoice at /documents/{id}/recurring [src: app/controllers/v3.php:1127]. The available cadences are a closed set: off, every 7, 14 or 28 days, or every 1, 3 or 12 months [src: app/controllers/v3.php:226]. The every-two-weeks option exists because that is the shape a good deal of retainer billing actually takes [src: app/controllers/v3.php:229].
Due invoices are generated by calling /cron/recurring from your own scheduler [src: app/controllers/v3.php:1119]. Two things about that call matter:
- It is authenticated by the backup token, passed as
?t=…, and returns HTTP 401 without it [src: app/controllers/v3.php:882]. Mint that token with the Scheduled-backup token action,/settings/backup-token[src: app/controllers/v3.php:1122]. Note where it appears: the action redirects to the Security page, and the token is displayed there once and never again [src: app/controllers/v3.php:725]. - It creates drafts. The response reports how many were drafted, and that call sends nothing to anybody [src: app/controllers/v3.php:903]. (Automatic payment reminders, below, have their own switch and their own route.)
Automatic payment reminders
Until version 3.2, Invora never e-mailed a client without somebody pressing a button. Automatic reminders are the one exception, so they are off until an administrator switches them on [src: app/src/Database.php:158], from their own form on the settings page [src: app/controllers/v3.php:1130]. Nothing is sent unless three things agree: the reminder switch, client e-mail (the master switch described under Sending by e-mail), and your own SMTP details [src: app/src/AutoRemind.php:234].
What you approve
| Setting | Default |
|---|---|
| Steps, in days from the due date (a negative number is before it) | -3, 0, 7, 14 [src: app/src/Database.php:159]; up to six steps, each from -30 to 365 [src: app/src/AutoRemind.php:40] |
| The most reminders sent in one day | 20 [src: app/src/Database.php:160] |
| One subject and message per step | the default wording [src: app/src/AutoRemind.php:96], written into your settings on the first request and never rewritten by a later version [src: app/src/Database.php:282] |
The messages take six placeholders: {client}, {number}, {amount_due} (what is still owed), {due_date}, {pay_link} and {business} [src: app/src/AutoRemind.php:48]. A line holding {pay_link} is left out while you have no payment link [src: app/src/AutoRemind.php:460]. Every reminder ends with the client's own link to the invoice, and a part-paid invoice also states what was received and what remains [src: app/src/AutoRemind.php:473].
Saving with the switch on writes the approval to the audit log (the steps, the cap and a fingerprint of the wording) and opens the preview [src: app/controllers/v3.php:974].
Preview today
/reminders/preview lists exactly which invoices would receive which step today, with the full message each one would get, which invoices are held and why, and when the rest are next due [src: app/controllers/v3.php:966]. Opening it sends nothing: it reads the same plan the sender acts on [src: app/src/AutoRemind.php:398].
Which invoices, and when
- Only invoices that are sent, part-paid or overdue with money still owed; never a draft, an estimate or a paid invoice [src: app/src/AutoRemind.php:247].
- Each step goes to an invoice at most once [src: app/src/AutoRemind.php:54].
- A step keeps trying for up to seven days after its own day, and stops when the next step's day comes [src: app/src/AutoRemind.php:328]. An invoice first seen long after its due date therefore gets one reminder, for the step it is at.
- A step whose day falls on or before the invoice's issue date is skipped [src: app/src/AutoRemind.php:314].
- No reminder goes on the day, or the day after, another mail about the same invoice reached the client [src: app/src/AutoRemind.php:376].
- A paused invoice, or an invoice of a paused client, is skipped [src: app/src/AutoRemind.php:360].
- The invoice is read again just before each send, so a payment recorded a moment earlier stops the reminder [src: app/src/AutoRemind.php:502].
How they go out
The schedule runs whenever somebody opens the dashboard, a few reminders per page load [src: app/controllers/app.php:131], and from /cron/reminders, authenticated by the same backup token as /cron/recurring [src: app/controllers/v3.php:934]. Each attempt first claims its slot, unique per day, invoice and step, so two page loads can never both send the same reminder [src: app/src/ClientMail.php:248]. A send the mail server refuses is recorded as failed in the invoice's send log and tried again on a later day inside the step's window, never twice on one day [src: app/src/AutoRemind.php:373]; a refusal also ends that run [src: app/src/AutoRemind.php:525].
Pausing
Pause one invoice from the invoice's page [src: app/controllers/v3.php:1132], or every invoice of a client from the client's page [src: app/controllers/v3.php:1133]. Clerks can pause and resume as well as administrators, and each change is recorded in the audit log [src: app/controllers/v3.php:1000].
What it never does
It never takes a payment, and a reminder never adds a fee: late fees are a separate rule of your own, off until you switch it on (see Late fees below) [src: app/src/LateFee.php:159]. When a fee is on an invoice, the reminder names it [src: app/src/AutoRemind.php:469]. No API route or AI tool can send, pause or edit a reminder; they can read the schedule at /api/reminders [src: app/index.php:141] and through the reminder_schedule tool [src: app/controllers/v3.php:191]. Loading demo data switches the schedule off, because the sample clients' addresses are not yours to mail [src: app/bin/seed.php:32].
Payment reminders by text
Invora can also send the automatic payment reminders as text messages, through an SMS gateway you already pay for (Twilio, Vonage, MessageBird, ClickSend or a local provider). It is off until an administrator switches it on, in Settings → Payment reminders by text [src: app/src/Database.php:209], [src: app/controllers/sms.php:11].
Who pays for the texts. You do, through your own gateway account, at your carrier's rate. Invora sells no credits and adds nothing to the price.
Any HTTPS gateway
Invora sends one HTTPS POST per text, as JSON or form-encoded, with your three field names for the number, the sender and the message (to,from,text by default; Twilio's are To,From,Body). Your key goes as a Bearer token, as HTTP Basic (user:password; for Twilio, AccountSid:AuthToken) or not at all. Any 2xx answer counts as sent [src: app/src/Sms.php:40]. The address has to start with https:// [src: app/src/SmsRemind.php:73]. Send a test text on the same page sends one text and shows your gateway's answer as it came. The key is never shown again, and it is left out of the shareable backup [src: app/src/BackupRestore.php:41].
Only clients who agreed
A client gets texts only after you tick This client agreed to get payment reminders by text on their record. The box starts unticked. Invora records the date you ticked it and writes an entry to the audit log; unticking it withdraws the agreement the same way [src: app/controllers/app.php:489]. The agreement is part of the database query that chooses who gets texted [src: app/src/SmsRemind.php:162], and it is checked again just before each text [src: app/src/SmsRemind.php:218]. A client with no phone number gets no texts.
How a client stops them. Every text ends with a stop link. It opens a page with one button, Stop these texts. Opening the link changes nothing until the client presses the button, so a link preview in a messaging app cannot stop texts by accident [src: app/controllers/sms.php:68]. Pressing it turns the agreement off and writes an entry to the audit log [src: app/src/SmsRemind.php:257]. Email reminders are not changed. Anonymizing a client also turns texts off and blanks the numbers in the text log [src: app/controllers/v3.php:474], [src: app/controllers/v3.php:431].
When texts go
- On the reminder schedule. Texts use the same steps, the same seven-day window per step and the same pauses per invoice and per client as the email reminders. They only go while automatic reminders are switched on [src: app/src/SmsRemind.php:73]. Email itself does not have to be set up.
- Inside your texting hours. 09:00 to 20:00 by default, in the timezone in Settings [src: app/src/Database.php:216]. A text that is due outside those hours waits for them [src: app/src/SmsRemind.php:201].
- Once per step. Each invoice and step is claimed in the text log before the gateway is called. The claim is unique in the database, so a step texts a client once however often the schedule runs [src: app/src/SmsRemind.php:174]. Each invoice has its own claims, so a new recurring invoice starts fresh.
- Not two days running. No text goes on the day, or the day after, another text about the same invoice [src: app/src/AutoRemind.php:376].
- At most the daily cap. The same number as the email reminders, counted separately [src: app/src/SmsRemind.php:214].
What a text says
Two messages: one for before or on the due date, one for after it [src: app/src/SmsRemind.php:32]. They take the same placeholders as the email reminders: {client}, {number}, {amount_due}, {due_date}, {pay_link} and {business}. A line holding {pay_link} is left out while you have no payment link. If your wording leaves out your business name, Invora adds it at the start. The stop link is always added at the end [src: app/src/SmsRemind.php:90], [src: app/src/SmsRemind.php:113].
When the gateway refuses
A refused text is never fatal. It is recorded with your gateway's answer and shown on the dashboard, on the invoice page under Text log, and on Preview today [src: app/src/SmsRemind.php:239]. The run stops there, because a gateway that refused one text will likely refuse the next. Invora does not try the same text again that day. It tries again on a later day, while the step is still current, at most once a day. A text whose answer never came back counts as sent, so a client never gets the same text twice. A demo install records each text as demo and calls no gateway [src: app/src/SmsRemind.php:227].
Late fees
Off until you switch it on, in Settings → Late fees [src: app/controllers/v33.php:15]. You write one rule: a flat amount, or a percentage of what is still unpaid on the invoice itself, never of earlier fees [src: app/src/LateFee.php:90]; a number of days after the due date; and once, or again every 30 days while unpaid, up to a limit you set [src: app/src/LateFee.php:123]. A percentage above 25% is refused as a likely typing slip.
The rule is never retroactive: it applies only to invoices falling due on or after the day you switched it on, a date you can change on the same form [src: app/src/LateFee.php:115]. Drafts, estimates and invoices with nothing owed are never charged, each invoice can be paused on its own page [src: app/controllers/v33.php:71], and an install that was off for months adds one fee, then the next 30 days later, never a back-dated burst.
A fee is added on dashboard loads and by /cron/reminders before the day's reminders go out, so a reminder states the new amount [src: app/controllers/app.php:126]. It is its own row, not an invoice line, added after the discount and the tax [src: app/src/Money.php:64]: a percentage discount never shrinks it, editing the invoice never touches it, and a recurring schedule never copies it into next month's draft. The client sees it on the invoice, the print view and the PDF, dated. Each fee is on the audit trail with the rule that added it, and any fee can be waived from the invoice: the total drops, the record stays, and the rule never charges that fee again [src: app/src/LateFee.php:201]. Preview today lists the fees the rule adds today next to the reminders.
Invora applies the rule you write. Whether you may charge a late fee, and how much, is set by your own terms with the client and the law where you trade.
Stripe payments mark the invoice paid
When your payment link is a Stripe Payment Link, Stripe can tell Invora the moment a client pays. In your Stripe dashboard, add a webhook endpoint at https://<your install>/pay/stripe with the events checkout.session.completed and checkout.session.async_payment_succeeded, then paste the endpoint's signing secret into Settings → Stripe payments mark the invoice paid and switch it on [src: app/controllers/v33.php:96].
Invora adds each invoice's reference to your buy.stripe.com link wherever it shows or mails it [src: app/src/PayHook.php:52], and Stripe hands that reference back with the payment. A link on your own domain can name the invoice instead, with a metadata field called invoice holding the invoice number [src: app/src/PayHook.php:95]. Every notice is checked against your signing secret and refused if it is more than five minutes old [src: app/src/PayHook.php:67]; each Stripe event is recorded once however often Stripe retries it [src: app/src/PayHook.php:131]. The payment is recorded like any other, as Stripe, so the invoice shows paid, the paid webhooks fire, and no further reminder goes [src: app/src/PayHook.php:164]. A payment in another currency, or for an invoice Invora cannot find, changes nothing and is listed under Latest notices from Stripe with the reason. Invora never sees a card. The signing secret is left out of the shareable JSON backup.
The client portal
Each client can have one private link to their own page, made on the client's page [src: app/controllers/v33.php:216]: open invoices with your payment button, estimates waiting for an answer, 24 months of history, and a statement PDF of every open invoice and the last 12 months [src: app/controllers/v33.php:172]. It never shows a draft or anyone else's documents [src: app/controllers/v33.php:132], and it asks search engines not to index it and browsers not to pass the link on [src: app/controllers/v33.php:159]. Replace link makes a new one and the old one stops working; Switch off closes it.
More than one address per client (from 3.3)
A client can have up to five more addresses under Also send to (accounts payable, a bookkeeper). Every email Invora sends that client (an invoice, or a reminder, scheduled or by hand) also goes to each of them as its own send, recorded in the outbox like the first [src: app/src/ClientMail.php:43]. The copies go only once the client's own address was accepted, so a mail server that refused the first is not asked again. A mistyped address is refused when the client is saved, and nothing is saved.
Custom fields on invoices and estimates (from 3.4)
Name up to five fields in Settings → Custom fields, such as PO number, Project or Job site [src: app/controllers/v34.php:11]. Two fields cannot share a name, and a name can be up to 40 characters [src: app/src/DocFields.php:48]. Every invoice and estimate then has a box for each named field in the editor, up to 120 characters [src: app/src/DocFields.php:80]. A filled-in field prints under Billed to on the invoice, the print view and the client's page [src: app/views/partials/invoice-paper.php:36], and in the PDF [src: app/controllers/v3.php:356].
The values travel with the document: an estimate turned into an invoice keeps them [src: app/controllers/v33.php:254], and a recurring invoice's next draft copies them [src: app/controllers/v3.php:795]. The API returns them as fields, by name, and takes them the same way when an invoice or estimate is created, for example "fields": {"PO number": "4471"}; a name the install does not have is refused [src: app/src/DocFields.php:98] [src: app/src/Api.php:112]. Renaming a field keeps its values; clearing a name hides the field but keeps what was typed, so naming it again brings it back.
Online estimate answers
Off until you switch it on in Settings → Client portal and online estimates [src: app/controllers/v33.php:317]. Then a sent estimate that is still within its valid until date shows Accept and Decline on its link; the client types their name, and may add a note [src: app/controllers/v33.php:268]. An estimate is answered once: a second click, or a second person, changes nothing [src: app/controllers/v33.php:286]. You are told by email (the Estimate accepted notice), the answer is on the estimate's timeline in the client's name, and an accepted estimate becomes a draft invoice with the same lines for you to review and send [src: app/controllers/v33.php:249]. Nothing is billed by the client's click.
Users, roles and what each may do
Invora has exactly two roles: clerk and admin [src: app/controllers/v3.php:205].
Administrators outrank every other role by design: a check for any named role also passes for an administrator [src: app/src/Auth.php:37]. An unknown or missing role is treated as admin, which means an upgrade never locks an existing user out [src: app/src/Auth.php:38].
The division of work
Of Invora's 104 registered routes, 36 are administrator-only. Broadly, a clerk does the daily work and an administrator owns the installation.
A clerk may create, edit, view, print and send documents; record payments; create and edit clients; upload attachments; save views; use the REST API and the AI endpoint; and manage their own two-step verification.
An administrator additionally holds:
| Area | Administrator-only routes |
|---|---|
| Settings | /settings, /settings/oidc, /settings/backup-token, /settings/ical-token [src: app/controllers/app.php:540], /settings/reminders [src: app/controllers/v3.php:956], /reminders/preview [src: app/controllers/v3.php:987] |
| Users | /users, /users/invite, /users/role, /users/active [src: app/controllers/v3.php:209] |
| Audit | /audit, /audit.csv [src: app/controllers/v3.php:315] |
| Backup and restore | /backup.json, /backup.sqlite, /restore, /restore/commit [src: app/controllers/api.php:370] |
| Import | /import/clients [src: app/controllers/app.php:634] |
| Items and tax | /items/save, /items/{id}/delete, /taxes/save, /taxes/{id}/delete [src: app/controllers/app.php:519] |
| Deletions | /documents/{id}/delete, /payments/{id}/delete [src: app/controllers/app.php:391] |
| API credentials | /api-settings and every key and webhook action beneath it [src: app/controllers/api.php:288] |
| Data protection | /clients/{id}/gdpr.json, /clients/{id}/anonymize [src: app/controllers/v3.php:1107] |
| Demo data | /load-demo, /reset-data [src: app/controllers/app.php:634] |
A clerk who attempts one of these is told they lack permission and returned to the dashboard rather than shown an error page [src: app/src/Auth.php:40].
Inviting somebody
Invite a colleague from /users/invite [src: app/controllers/v3.php:220]. The invitation is a single-use token that expires after seven days [src: app/controllers/v3.php:229], and only its hash is stored — the same discipline Invora applies to API keys [src: app/src/RolesKit.php:50]. If the posted role is not one of the two valid roles, the invitation is created as a clerk [src: app/controllers/v3.php:222].
The recipient accepts at /invite/{token}, choosing their own password [src: app/controllers/v3.php:1100]. Acceptance is checked in constant time and against the expiry [src: app/src/RolesKit.php:63].
The last administrator is protected
Invora will not let you lock everybody out. Demoting the only remaining active administrator is refused [src: app/controllers/v3.php:244], and so is deactivating them [src: app/controllers/v3.php:263].
Settings reference
Settings are administrator-only, at /settings [src: app/controllers/app.php:556]. Every value below is saved from that one form [src: app/controllers/app.php:559].
Your business
| Setting | Notes |
|---|---|
| Business name | Defaults to My Business; stored up to 190 characters [src: app/controllers/app.php:568] |
| E-mail, phone, address, website | Phone is limited to 60 characters, the others to 190 [src: app/controllers/app.php:569] |
| Logo | Either a URL in the logo field, or an uploaded file. The upload wins over the URL [src: app/controllers/app.php:597] |
| Accent color | Defaults to #4f46e5 [src: app/controllers/app.php:572] |
| Time zone | Validated against the system's own zone list; anything unrecognized falls back to UTC [src: app/controllers/app.php:562] |
An uploaded logo is checked by its magic bytes rather than its file name, and replacing one deletes the old file [src: app/controllers/app.php:597]. Logo changes are written to the audit log [src: app/controllers/app.php:601].
Documents
| Setting | Default |
|---|---|
| Currency code | USD [src: app/controllers/app.php:573] |
| Currency symbol | $ [src: app/controllers/app.php:573] |
| Invoice number prefix | INV- [src: app/controllers/app.php:574] |
| Estimate number prefix | EST- [src: app/controllers/app.php:574] |
| Default terms, footer note | Free text, no length limit applied [src: app/controllers/app.php:575] |
Outgoing e-mail
Host, port, username, sender address and encryption are all set here [src: app/controllers/app.php:578]. Two details are worth knowing:
- The port is clamped to a valid range and defaults to 587 [src: app/controllers/app.php:579].
- Encryption must be one of
starttls,sslornone, and anything else becomesstarttls[src: app/controllers/app.php:581]. - The password is only overwritten when you actually type one, so re-saving the form does not wipe a stored password [src: app/controllers/app.php:584].
Notifications
Notifications to you — invoice paid, estimate accepted — are separate from notifications to your client [src: app/controllers/app.php:582]. The client-facing ones are all off unless switched on [src: app/controllers/app.php:588]. Automatic payment reminders have their own form on the same page, described under Automatic payment reminders.
Backup, restore and the scheduled backup
Taking a backup
Two formats, both administrator-only. /backup.json writes every user table as pretty-printed JSON, stamped with the application name, version and an export timestamp [src: app/src/BackupExport.php:19]. /backup.sqlite hands you the raw database file [src: app/src/BackupExport.php:46]. Internal SQLite bookkeeping tables are excluded from the JSON [src: app/src/BackupExport.php:59]. The JSON is the one you can hand on: it and the scheduled backup leave out the same list of credentials — password and API-key hashes, webhook and single sign-on secrets, the SMTP password, two-factor seeds and recovery codes, and the hashes of the backup and calendar tokens [src: app/controllers/v3.php:648].
Restoring
Restoring is deliberately two steps. Upload the file at /restore, then confirm at /restore/commit [src: app/controllers/v3.php:1119]. The intermediate step is what lets you see what you are about to overwrite before you overwrite it. Restoring and minting the backup token are an administrator's; a signed-in clerk gets a 403 [src: app/controllers/v3.php:635].
Unattended backups
Mint a backup token with /settings/backup-token [src: app/controllers/v3.php:1122], then have your scheduler fetch /backup/scheduled [src: app/controllers/v3.php:1121].
Three details to get right. The token is prefixed ibk_ [src: app/controllers/v3.php:719]. It is shown once, on the Security page rather than the settings page you minted it from [src: app/controllers/v3.php:725]. And the same token authenticates the recurring-invoice run, so one secret covers both scheduled jobs [src: app/controllers/v3.php:882].
Importing clients from a CSV file
Administrators import at /import/clients [src: app/controllers/app.php:634]. Columns are matched by header name, not by position, and the matching ignores case, spaces and underscores while accepting a few common aliases [src: app/src/CsvImport.php:5]. The client name is the only required column; e-mail and the rest are optional [src: app/src/CsvImport.php:11].
The REST API
Manage keys at /api-settings [src: app/index.php:123]. Keys carry the prefix apk_ and are shown once — only a hash is kept [src: app/src/Api.php:83].
Two scopes
A key is either read or full, and anything that is not exactly read is stored as full [src: app/src/Api.php:86]. A read-only key is refused at the door on any write, rather than part-way through [src: app/src/Api.php:42].
Endpoints
/api/ping, /api/invoices, /api/estimates, /api/documents/{id}, /api/clients, /api/items and /api/taxes read; invoices, estimates, clients, payments and document statuses can be written [src: app/index.php:129]. A machine-readable description is served from /api/openapi.json [src: app/index.php:142].
API requests authenticate with a bearer key and are exempt from cross-site request forgery checks by design, because they are not browser form posts [src: app/index.php:122].
Webhooks
Create and manage webhooks alongside API keys [src: app/index.php:125]. Invora fires six events:
| Event | When |
|---|---|
invoice.created | a new invoice is created: in the editor [src: app/controllers/app.php:327], by converting an estimate or by a client accepting one on its link [src: app/controllers/v33.php:263], through the API or an AI tool [src: app/controllers/api.php:95], or by a recurring schedule [src: app/controllers/v3.php:847] |
estimate.created | a new estimate is created, in the editor, through the API or an AI tool [src: app/controllers/app.php:327] |
invoice.paid | a payment settles an invoice [src: app/controllers/app.php:415] |
payment.recorded | any payment is recorded [src: app/controllers/app.php:413] |
estimate.accepted | an estimate is marked accepted [src: app/controllers/app.php:364], or converted to an invoice [src: app/controllers/app.php:384], or accepted by the client on its link [src: app/controllers/v33.php:268] |
estimate.declined | the client declines an estimate on its link [src: app/controllers/v33.php:268] |
Each fires from both the web interface and the API, so an integration sees the same events either way [src: app/controllers/api.php:124]. A webhook can be paused without deleting it [src: app/controllers/api.php:336], and deleting one also clears its delivery history [src: app/controllers/api.php:343].
Connecting an AI assistant
Invora speaks the Model Context Protocol at /mcp [src: app/controllers/v3.php:1094]. Sending a GET returns 405 with an Allow: POST header rather than a confusing error [src: app/controllers/v3.php:30].
Ten tools are exposed: list_invoices, list_estimates, get_document, create_invoice, create_estimate, record_payment, set_status, overdue_report, list_clients [src: app/controllers/v3.php:56] and reminder_schedule, which reads the automatic payment-reminder schedule [src: app/controllers/v3.php:191]. No tool e-mails a customer, sends or pauses a reminder, or deletes anything.
The API key's scope is handed to the protocol core, so a read-only key gets read-only tools — the restriction is enforced per tool, not merely advertised [src: app/controllers/v3.php:37].
The calendar feed
Invoice dates can be subscribed to as a calendar. Mint a calendar token with /settings/ical-token [src: app/controllers/v3.php:1124] — like the backup token it is shown once, on the Security page [src: app/controllers/v3.php:643] — then point your calendar application at /calendar.ics?t=… [src: app/controllers/v3.php:1123]. Without a valid token the feed returns 401 [src: app/controllers/v3.php:614]. The feed covers invoices only, most recent first, up to 500 of them [src: app/controllers/v3.php:619].
Data-protection tools
Two administrator actions sit on each client. /clients/{id}/gdpr.json exports everything held about that client [src: app/controllers/v3.php:1107], and /clients/{id}/anonymize anonymizes the record in place [src: app/controllers/v3.php:1108].
Anonymizing is not the same as deleting: it is the option to reach for when a client asks to be forgotten but the documents must stay for your own accounting.
Anonymizing replaces the client's name, company, email, phone, address, notes and "Also send to" addresses, the numbers their texts went to, and the emails sent to them about their documents, with [removed]. The documents and their amounts stay.
Kept: the name and note a client typed when accepting or declining an estimate, because that is the record of their decision on the document.
The audit log
Administrators can read the log at /audit and export it at /audit.csv [src: app/controllers/v3.php:1103]. Recorded actions include settings saves, status changes, payments, recurring-invoice runs, role changes, invitations and their acceptance, and logo changes [src: app/controllers/v3.php:231], as well as the reminder schedule's approval [src: app/controllers/v3.php:974], its pauses [src: app/controllers/v3.php:1000] and every automatic reminder the mail server accepted or refused [src: app/src/AutoRemind.php:522].
Looking after your install (from 4.0)
Updates from inside Invora
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; Invora 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. Invora is unavailable for about a minute while this runs. The update replaces Invora'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 Invora 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 Invora 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: a small business's clients, invoices and estimates to try things on. You can remove it in one click. [src: app/install/index.php:108]. 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 clients, invoices and estimates? 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 the Security page), 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 Invora 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 Invora 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/invora/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].
Health check
/healthz returns {"ok":true,"app":"invora","version":"4.1.3"} [src: app/controllers/api.php:354]. It needs no administrator rights, which makes it usable by an uptime monitor.
Two things that are not for you to switch on
Both are mentioned because you may see them referred to elsewhere.
Public-demo mode. If a marker file named .demo-mode is present, Invora signs any visitor in as the first user so that a public demonstration needs no password, and it periodically resets itself [src: app/src/Auth.php:24]. That marker is never included in a shipped copy [src: app/src/Auth.php:23]. Your installation does not behave this way.
Hosted operation. Invora can boot against a database handed to it by a control plane instead of its own config.php, which is how a hosted edition works. A standalone installation never sets that value and behaves exactly as this manual describes [src: app/index.php:38].
Where things live
| Path | Contents |
|---|---|
app/index.php | the front controller and most route registrations [src: app/index.php:71] |
app/controllers/ | the request handlers, in three files [src: app/index.php:65] |
app/src/ | the modules — money, PDF, mail, API, roles, backup and the rest [src: app/index.php:7] |
app/views/ | the pages [src: app/views/layout.php] |
app/tests/run.php | the test suite [src: app/tests/run.php:3] |
app/API.md | the API reference [src: app/API.md] |
Connect it to other tools (Zapier, Make, n8n)
Invora 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 Invora 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 Invora'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 Invora from the console starts a fresh session [src: app/index.php:60].
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:20].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Invora'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 Invora 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 Invora 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 Invora'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].
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.