Confida Manual
Confida — User Manual
A confidential reporting channel you host yourself. Version 4.1.3 [src: app/controllers/api.php:26].
About this manual
Every statement here was written by reading Confida'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 — which matters more here than in most products, because somebody may rely on it.
What Confida is
A reporting channel with two sides. Somebody submits a report without an account [src: app/index.php:69]; a handler works it; and the two can exchange messages without the reporter ever identifying themselves [src: app/index.php:77].
Making a report
A report's description and each message in the thread are accepted up to 100,000 characters; longer text is refused with a message rather than lost [src: app/src/Report.php:65].
The reporting form is public and needs no sign-in [src: app/index.php:69]. On submission the reporter is given a case code and sets a passphrase.
The case record stores no IP address. The table holds the code, the passphrase hash, a category, subject, details, status, the clocks and the timestamps — and nothing that identifies the machine the report came from [src: app/src/Database.php:210]. The one place the application looks at a caller's address is rate-limiting for staff logins [src: app/controllers/app.php:14].
Questions per category
Under Settings, Extra questions per category takes one Category | Question per line, up to five per category; a line whose category is not in the category list is dropped [src: app/controllers/v3.php, cf_category_questions()]. When a reporter picks that category, the form asks those questions too, each optional. Only the chosen category's answers are kept, each stored with the question as it was asked, so rewording a question later does not change what an earlier reporter answered. The handler sees them under the report; they are withheld on a sealed case with the rest of the text, carried as answers in the API and the MCP case_detail tool, and cleared by the retention purge. Write the questions in the language your reporters use: they are shown as typed.
Save and finish later
A reporter who is not ready to send can press Save and finish later. What they have written, including answers to the category's questions, is kept under a draft code and their passphrase for 30 days [src: app/controllers/app.php, public_report_save_draft()]. A draft is not a case: no handler can see it, no clock starts and nothing is sent to anyone. Resume a saved report, with the draft code and the passphrase, puts it back in the form; sending the report deletes the draft, and a draft that is never finished is deleted after 30 days. A draft carries no identity, like a case. The passphrase is required to save, because it is the only way back.
Coming back to a report
A reporter returns through the tracking page with their code and passphrase [src: app/index.php:75].
The code is generated from an alphabet without look-alike characters and checked for uniqueness, throwing rather than issuing a duplicate [src: app/src/Report.php:158]. When it is typed back in it is normalized — upper-cased, with spaces and dashes stripped — so a code written on paper and read back with hyphens still works [src: app/src/Report.php:171].
The passphrase is hashed, not stored [src: app/src/Report.php:176], and checked by verification rather than comparison [src: app/src/Report.php:181]. The consequence is worth stating plainly: nobody can recover it, including you. A reporter who loses their passphrase loses access to that thread, and no administrator can restore it. That is the cost of the guarantee.
There is a sign-out on the tracking page which clears the session [src: app/controllers/app.php:361]. It exists for shared or borrowed computers.
The reporter's language
The reporting pages come in ten languages: English, German, French, Spanish, Italian, Polish, Dutch, Portuguese, Romanian and Greek. Each ships as code in app/lang/, never as something typed in the browser [src: app/src/helpers.php]. In Settings, Public reporting pages language is the default, and Also offer the reporter these languages adds more; the handler console stays English.
When more than one language is offered, the public pages show a language picker. A reporter lands in the offered language their browser asks for, or in the default, and can switch at any time; the choice holds for the visit [src: app/src/helpers.php, pub_lang_begin()]. The one-time "save your code" screen has no picker, because reloading it would lose the code before it is written down.
The case records the language the report was written in, and the case page shows it as Reporter's language, so you can reply in it. The reporter is told this on the form before they send. On a sealed case it is withheld with the text until the two-person rule is met. It is on the case in the API and the MCP tools as reporter_lang, and it is left out of webhook payloads.
The German pages use the HinSchG's own vocabulary; each of the other languages follows its national transposition of the Directive (for example lanceur d'alerte and signalement in French, zgłoszenie in Polish, melding in Dutch). The translations were drafted for this release: have a native speaker read the pages in any language you offer before your people rely on them. The introduction text you set in Settings is shown as you typed it, in every language.
Attachments
Attachments get two separate protections, and the order matters.
First, the file is stripped
An attachment is accepted only if its identifying metadata can be removed by parsing the file itself [src: app/src/Scrub.php:33].
For a JPEG, every application marker segment is removed — which is where EXIF and GPS live, along with maker notes, the embedded thumbnail, XMP and Photoshop resource blocks — and comment segments go too [src: app/src/Scrub.php:45]. For a PNG, only the critical chunks and transparency survive; the text chunks that carry author, software and comments are dropped, as are the EXIF and modification-time chunks [src: app/src/Scrub.php:77].
Anything else is refused rather than accepted. The source gives the reason: a PDF or an office document carries author metadata that cannot be reliably removed without a full parser, so the product says no instead of pretending [src: app/src/Scrub.php:3].
That refusal is the feature. A product that accepted a PDF and quietly failed to clean it would be worse than one that accepts nothing.
Then the bytes are sealed
What survives scrubbing is encrypted at rest with AES-256-GCM, under a key derived from the installation's own secret [src: app/src/Vault.php:26]. The key is derived rather than used raw, and is never logged [src: app/src/Vault.php:20].
The trade is stated in the source and you should understand it before relying on backups: losing config.php means losing the attachments [src: app/src/Vault.php:3]. That is intended. It is what makes a stolen backup tape, a copied VM snapshot or a shared-host neighbor unable to read an attachment they can otherwise reach.
Who can open one
An attachment is served to the reporter whose session owns the case, or to a handler with the case permission [src: app/controllers/app.php:341]. Where a case is sealed under the two-person rule, a handler is refused outright [src: app/controllers/app.php:344].
Statistics
Statistics shows, for the last 12 months, this year or dates you choose, the reports received month by month and by category, drawn as bars; how many were acknowledged on time, late or not yet; the median days to acknowledge and to close; closed and dismissed; and what is open and overdue today [src: app/src/Report.php:384]. Counts and dates only: nothing a report says, no reporter detail. An empty period shows no medians rather than a zero. In a small organization even a count can point at a person, and the page says so.
The two-person rule
A case can be marked so that no single handler can open it alone [src: app/controllers/v3.php:108].
Opening one takes two steps by two different people. A handler requests access; then a handler other than the one who requested it must confirm before the case's details and message history can be read or answered — and both names are recorded permanently [src: app/controllers/v3.php:114].
Until that happens the case stays sealed, and the refusal says so rather than presenting an empty page [src: app/controllers/app.php:344].
The clocks
Two deadlines run on every case.
Acknowledgment is a number of days from receipt, seven by default [src: app/src/Report.php:79].
Feedback is a number of months, three by default, anchored on the date the case was actually acknowledged where that happened, and on the acknowledgment deadline where it did not [src: app/src/Report.php:122]. A case that was never acknowledged does not get a later feedback deadline for having been ignored.
Statutory and observed dates are kept apart
This is the design decision worth understanding. The statutory dates are never adjusted. A second, observed date is computed alongside them, moving the deadline forward off a weekend or one of your own non-working days, and both are shown [src: app/src/Report.php:89].
The observed date can only ever move later, so nothing becomes overdue sooner because the feature exists [src: app/src/Report.php:91].
Your non-working days come from a holiday table you maintain [src: app/controllers/app.php:736], read into the calculation when deadlines are worked out [src: app/controllers/app.php:448]. Nothing is hardcoded: the source is explicit that the product ships no country's calendar and guesses no jurisdiction [src: app/src/Report.php:94].
Status is derived
A case's service-level status is worked out from the row and today, never stored [src: app/src/Report.php:134]. Closed and dismissed cases report as on track — the clock stops when the matter does. Otherwise a passed feedback deadline reports as feedback overdue, and an unacknowledged case past its acknowledgment deadline reports as acknowledgment overdue [src: app/src/Report.php:134].
Owners and reminders
A case can have a named owner, chosen from the active handlers and administrators; the choice is written to the case log [src: app/controllers/v3.php:369]. When reminders are switched on in Settings, the owner and the handler notification address are emailed before the acknowledgment and feedback deadlines (3 and 1 days before by default) and once on the day after either passes [src: app/controllers/v3.php:278]. Like every Confida email, a reminder carries the case code and a date, never report content. Each step is sent once; it is checked when someone opens the dashboard, and a daily cron call to reminders/run with the scheduled-backup token covers days nobody does [src: app/controllers/v3.php:354]. A reminder that could not be delivered is recorded on the case log and shown on the dashboard.
Escalation. Settings can name an escalation address (someone above the owner) and a number of days. A case whose acknowledgment or feedback deadline is still unmet that many days after it passed is emailed to that address once per deadline, with the case code, the date, how late it is and who owns it, never report content [src: app/controllers/v3.php, cf_reminders_watch()]. It is a separate switch from the reminders and works with them on or off; 0 days or an empty address means no escalation. The escalation is written to the case log.
Linked cases
Two reports about the same matter (the same supplier, the same manager, a follow-up filed as a new report) can be linked by a handler from either case, by typing the other case's code and an optional note [src: app/controllers/v3.php, case_link_add()]. Each case then lists the other; both case logs record the link and its removal. A link copies nothing from one report into the other, a sealed case cannot be linked until the two-person rule is met, and a sealed case does not show its links. The API and the MCP case_detail tool list linked codes as linked.
Handler notifications and reminders go through your own SMTP server. The encryption is a setting: STARTTLS (587), SSL/TLS (465) or none [src: app/controllers/app.php:966].
Your team's chat
Besides the email, new reports and reporter replies can post one line to your team's chat: paste an incoming-webhook address from Slack, Microsoft Teams, Google Chat or Discord under Settings [src: app/src/helpers.php:243]. The line says what happened and the case code, "Sign in to review it; this message contains no report details", and nothing from the report, exactly like the email. The same switches (new report, reporter reply) govern both; nothing is posted from the public demo. Send a test line shows what the chat answered, and a refused post is written to the audit trail. The address is itself a credential, so the shareable JSON backup leaves it out; re-enter it after a restore.
Users, roles and what each may do
Three roles [src: app/controllers/v3.php:57].
| Permission | viewer | member | admin |
|---|---|---|---|
case.view | yes | yes | yes |
audit.view | yes | yes | yes |
case.handle | — | yes | yes |
case.read_thread | — | yes | yes |
views.save | — | yes | yes |
| everything else | — | — | yes |
Note that reading the message thread is its own permission, separate from seeing that a case exists [src: app/controllers/v3.php:57].
The API and agent access
Machine access uses a key [src: app/index.php:100]. Cases can be listed and fetched, a status set, and the service-level position pulled [src: app/index.php:101]. Fetching one case with its report and thread applies the same two gates as the screen and the agent tool: the key's role must be allowed to read threads, and a sealed case is refused until the two-person rule is met (both were missing on that REST route before 3.2.0) [src: app/controllers/api.php, api_case_get()].
Four tools are exposed to an agent, of which one writes [src: app/controllers/v3.php:57]:
| Tool | Writes? | What it does |
|---|---|---|
list_cases | no | Cases with derived service-level status |
case_detail | no | One case with its report, action log and message thread |
stats | no | Counts by workflow and service-level status, and what is overdue |
post_handler_message | yes | Posts a message to the reporter on a case |
The writer always posts as the handling side rather than impersonating an individual [src: app/controllers/v3.php:57].
What Confida does not do
It does not make a reporter anonymous. It stores no IP address against a case [src: app/src/Database.php:210] and requires no account [src: app/index.php:69], which is what the application controls. It does not control your web server's access logs, your network, your reverse proxy, or the fact that a report's contents may identify its author to anyone who knows the department. Telling somebody they are anonymous because the software stores no address would be an overstatement, and this manual will not make it.
It cannot recover a lost passphrase [src: app/src/Report.php:176]. That is a guarantee, not a defect, and it should be explained to reporters when they are given a code.
It refuses attachments it cannot clean [src: app/src/Scrub.php:3]. Images it can strip are accepted; documents are not, because their metadata cannot be reliably removed.
It does not decide anything. It records reports, runs two clocks and keeps a trail. Whether a report is well-founded, and what is done about it, are yours.
Looking after your install (from 4.0)
Updates from inside Confida
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; Confida 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. Confida is unavailable for about a minute while this runs. The update replaces Confida'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 Confida 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 Confida 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: six anonymous cases across every deadline state 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 cases? 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:101]. 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 Confida 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 Confida 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/confida/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)
Confida 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 Confida 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. Accepting an invite starts a fresh session. Single sign-on starts a fresh session. 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 Confida'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.
Accepting an invite now starts a fresh session (a new session id), as every other sign-in already did [src: app/controllers/v3.php:1044].
Single sign-on now starts a fresh session too [src: app/controllers/v3.php:1092].
On Ownware Cloud, opening Confida from the console starts a fresh session [src: app/index.php:58].
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 Confida'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 Confida 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 Confida 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 Confida'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].
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.