Home›Helpora›Manual
Helpora · Manual

Helpora Manual

Helpora — User Manual

A support desk you can be honest about: requests, threads, response and resolution targets, and a clock that cannot be improved without answering somebody. Version 4.1.3 [src: app/src/App.php:8].

About this manual

Every statement here was written by reading Helpora'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.

Nothing is described that the code does not do. Where something is deliberately limited, or currently wrong, that is said plainly rather than left out.

⭐ The clock is the product

The source opens with the argument the whole design rests on [src: app/src/Request.php:5]:

Every help desk reports response times. Almost every one of them can be made to report better ones than it earned, because the timer is reset by things that are not an answer: an internal note, a reassignment, a status change, an auto-reply. A desk whose numbers can be improved without answering anybody is not measuring service, it is measuring how well its agents know the tool.

Four rules follow, and all four are enforced in one file.

1. The first response happens once

first_response_at is stamped by the first message that actually goes to the requester, and is never written again [src: app/src/Request.php:13]. The write is guarded — WHERE first_response_at IS NULL — so two agents replying in the same second cannot both claim it: the loser matches zero rows and the earlier stamp stands [src: app/src/Request.php:14].

Nothing else touches it: no note, no reassignment, no status change, no reopening [src: app/src/Request.php:16].

On the dashboard, a late first reply counts against First reply within target for good. Breaching now and Needs your action hold open requests only: once a late request is settled it asks nothing more of anyone, and its verdict stays in the percentage [src: app/controllers/app.php:436].

2. You cannot close what you never answered

Resolving or closing a request that has never had an outbound message is refused, because a desk that can close in silence will, and its numbers will look excellent [src: app/src/Request.php:19]. Executed, with the refusal it actually produces [src: app/src/Request.php:446]:

status new,  never answered  ->  "This request has never been answered. Write to the person who
                                  raised it before resolving or closing it — a desk that can settle
                                  in silence will, and its response times will look excellent."
status open, never answered  ->  same refusal
status open, answered        ->  allowed

3. The only pause is waiting on the requester, and it costs the first response nothing

You may stop the resolution clock while genuinely waiting for the person who asked — but only after you have written to them, because otherwise "waiting on the requester" means "waiting for them to guess we need something" [src: app/src/Request.php:23]. The pause ends by itself the moment they reply.

And the first-response clock cannot be paused at all: there is no state of the world in which you are waiting on somebody to hear back from you for the first time [src: app/src/Request.php:27].

Executed [src: app/src/Request.php:461]:

open, nothing sent yet  ->  "Nothing has been sent to the requester yet, so they are not the ones
                             being waited on. Ask them the question first, then mark it waiting."
open, already answered  ->  allowed
already resolved        ->  "That request is already settled."

4. A deadline only ever moves later

Targets land on working days; a weekend or one of your own non-working days rolls the observed date forward. The policy date itself is never rewritten, and both are shown, so nothing becomes breached sooner because a holiday was added [src: app/src/Request.php:30].

Executed with Monday 7 September marked as a company non-working day [src: app/src/Request.php:198]:

policy 2026-09-04 (Fri)  ->  observed 2026-09-04
policy 2026-09-05 (Sat)  ->  observed 2026-09-08
policy 2026-09-06 (Sun)  ->  observed 2026-09-08
policy 2026-09-07 (Mon, non-working)  ->  observed 2026-09-08

The same arithmetic applies to the hour, not just the day. A four-hour response target set at five o'clock on that Friday lands at one o'clock on the Tuesday once the weekend and the closed Monday are stepped over [src: app/src/Request.php:135]:

response, 4h from 2026-09-04 17:00   ->  2026-09-08 13:00
resolution, 2 days from the same     ->  2026-09-09

Targets per priority

Four priorities, each with a response target in hours and a resolution target in days [src: app/src/Request.php:61]. Executed:

urgent   1 hour    1 day          normal    8 hours   5 days
high     4 hours   2 days         low      24 hours  10 days

Requesters do not need an account

A request is identified by an unguessable code [src: app/src/Request.php:88] and followed up with a passphrase, of which only the hash is kept [src: app/src/Request.php:324]. There is no account to create and no password to reset.

An email address is optional. Replies always reach the requester on their request page, behind the reference and passphrase. With Settings → Email the requester switched on (it is off by default), a requester who gave an address is also emailed when you reply and when you resolve or close the request [src: app/src/helpers.php:175]. The email carries the reference, their own summary line and the address of the request page — never your reply, an internal note or their passphrase — and each send, or the reason it did not send, is written on the request's log. With no address, nothing can be emailed, which the interface says rather than hides [src: app/src/Request.php:36].

Mail goes through your own SMTP server; the Security setting offers STARTTLS (usually port 587), SSL/TLS (usually port 465) or none for a local relay.

Signed-in colleagues (1.1)

With Colleagues can sign in with this too ticked under Single sign-on, a requester opens /my and signs in through your identity provider — the same app registration and return address the agents use. What they get is an email address the provider vouched for, held in their session: My requests lists every request filed under it [src: app/controllers/v11.php:47], and one tap opens the thread with no passphrase [src: app/controllers/v11.php:94]. Someone else's request, or a code that does not exist, gets the same refusal. The request form fills in their address and asks for no passphrase. An address outside the domain you set, or one the provider marks unverified, is refused [src: app/controllers/v11.php:36]. Signing in as a requester never creates or opens an agent account.

A form robots cannot flood (1.2). The request form stays open to anyone, with nothing to prove, and still keeps robots out. It has a box people never see, which form-filling robots fill in; a send with that box filled is turned away. One connection can send at most 30 requests in ten minutes; change that under Settings (0 means no ceiling), and raise it for an office where everyone shares one internet address. The connection is kept only as a salted hash, for a day. A colleague signed in with the organization's sign-in, and a signed-in agent, are never counted [src: app/controllers/v11.php:313].

Month arithmetic

Where a month has to be added, it clamps to the end of the target month rather than overflowing [src: app/src/Request.php:97]. Executed against PHP's own:

2026-01-31 + 1 month   PHP: 2026-03-03    Helpora: 2026-02-28
2026-08-31 + 6 months  PHP: 2027-03-03    Helpora: 2027-02-28

Users and roles

Three roles [src: app/controllers/v3.php:60]. A member can handle requests and read the thread [src: app/controllers/v3.php:61]; admin holds everything.

A viewer is granted audit.view explicitly, written into the role by name rather than inherited from a wildcard [src: app/controllers/v3.php:60], and the audit page requires exactly that [src: app/controllers/v3.php:755]. On a desk whose value is the honesty of its numbers, letting a read-only account see who changed what reads as deliberate — but if you expected otherwise, change the role.

Backups are gated on settings.edit [src: app/controllers/api.php:241], which no role below admin holds [src: app/controllers/api.php:249].

Webhooks

The events you can subscribe to are the events the app sends [src: app/src/Webhook.php:22]:

eventsent when
request.createda request arrives through the form
request.first_responsethe first reply to a request is sent — once per request
request.messagethe requester writes back
request.status_changeda request moves, on the board, the request screen, in bulk or through the API
request.settleda request is resolved or closed

An endpoint with no events ticked receives all of them. An endpoint saved before this release under the older names case.created, case.message or case.status_changed receives the matching request.* event [src: app/src/Webhook.php:25]. Payloads carry the reference, the queue, the status and the clock facts — never the request text or a message body.

Chat channels (1.2). When you add a webhook, choose its format: signed JSON for your own receiver, or a Slack, Microsoft Teams, Google Chat or Discord incoming webhook. A chat channel gets one line per event: "New request K7Q2M9XR4T in IT problem, urgent.", "Request K7Q2M9XR4T has its first reply.", and so on, with a link to the request for the signed-in desk. The line carries the reference and what happened, and for a new request its queue and a high or urgent priority. It never carries the subject, a message or who asked, because a chat channel is read by more people than the desk [src: app/src/Webhook.php:38].

Saved replies, "did it help?" and help articles (1.1)

Saved replies live under Saved replies in the menu, for anyone who handles requests [src: app/controllers/v11.php:133]. On a request, "Insert a saved reply" drops one into the reply box with {reference} and {agent} filled in; nothing is sent until the agent sends it.

"Did it help?" Once a request is resolved or closed, the requester's page asks once — yes or no, and a line if they like [src: app/controllers/v11.php:171]. The answer is written once (a second one changes nothing), shown on the request and in its log, fired as request.rated, returned by the API, and summed on the dashboard over 30 days, overall and per queue. Settings → "Ask did it help?" switches it off.

Help articles are written under Help articles in the menu and read by colleagues at /help, by topic or by search [src: app/src/Kb.php:99]. A draft is visible only to the desk. As someone types a request's summary, the form suggests up to three matching articles [src: app/controllers/v11.php:228]; on a request, "Link a help article" puts an article's title and address into the reply. The mark-up is deliberately small, and everything is escaped before it is formatted, so an article cannot carry markup or script [src: app/src/Kb.php:44].

The API and agent access

Five tools [src: app/controllers/v3.php:405]: three read — the request list, one request in detail, and the statistics — and two write, posting a handler message [src: app/controllers/v3.php:464] and settling a request [src: app/controllers/v3.php:493].

The statistics tool reports first-reply performance as met, missed and never answered [src: app/controllers/v3.php:566] — the third figure being the one a desk with a silent-close habit would rather not publish.

An agent settling a request goes through the same refusal as a person: it cannot close what was never answered.

Data requests: export or erase a requester (4.1.1)

An administrator opens Data requests (under Set up). Pick a requester by the address they gave:

  • Download their data writes one JSON file with every request they raised, its thread, its log and its attachment records.
  • Erase their data replaces, on every request of theirs, the address, the subject line, the details they wrote, the resolution written back to them, their "did it help?" comment, every message in the thread and the notes in the request log with [removed], and deletes the photos they attached [src: app/controllers/v3.php:277].

The requests themselves stay: the reference, the queue, the status and both clocks with their stamps are the evidence of how the desk performed, and once the words are gone they name nobody. Erasure is refused while any of their requests is still open, because the desk may still owe them an answer [src: app/controllers/v3.php:317]. The audit trail records that an erase happened and how many requests it touched, never the address.

The retention purge in Settings is different: it empties the details, the messages and the resolution of every settled request after a number of days, and keeps the requester's address, the subject line, the "did it help?" comment, the request-log notes and attached photos. Use Data requests when one person asks.

What Helpora does not do

It does not let a note, a reassignment or a status change stamp a first response [src: app/src/Request.php:16].

It does not let two agents both claim the first reply [src: app/src/Request.php:14].

It does not allow a request to be settled in silence [src: app/src/Request.php:19].

It does not let you pause a clock before you have written to anybody [src: app/src/Request.php:23].

It does not pause the first-response clock at all [src: app/src/Request.php:27].

It does not move a deadline earlier [src: app/src/Request.php:30].

It does not store a requester's passphrase [src: app/src/Request.php:324].

Looking after your install (from 4.0)

Updates from inside Helpora

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; Helpora 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. Helpora is unavailable for about a minute while this runs. The update replaces Helpora'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 Helpora 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 Helpora 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: eight help requests in every 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 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 & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:102]. 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 Helpora 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 Helpora 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/helpora/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)

Helpora 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 Helpora 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 Helpora'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:1055].

Single sign-on now starts a fresh session too [src: app/controllers/v3.php:1107].

On Ownware Cloud, opening Helpora 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:21].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Helpora'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 Helpora 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 Helpora 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 Helpora'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.

← Back to Helpora · Quickstart · API · Test run