Home›Permora›Manual
Permora · Manual

Permora Manual

Permora — User Manual

Construction permits and the inspections they require, tracked on your own server. Version 4.1.3 [src: app/controllers/api.php:17].

About this manual

Every statement here was written by reading Permora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

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

What Permora is, and what it is not

It tracks permits the contractor has already pulled and the inspections those permits require.

The source states the boundary in one sentence, and it is worth quoting because it removes four separate misunderstandings at once: Permora does not file permits with any authority, collect or move money, sign anything, or give legal advice. It is a record-keeping and reminder tool only [src: app/src/Permits.php:16].

Nothing here is legal advice, and nothing in the product substitutes for your authority having jurisdiction.

The date maths, and why it is the core value

The source calls portable date arithmetic the core value of the product [src: app/src/Permits.php:8], and two decisions make it trustworthy.

Both endpoints are pinned to midnight UTC, so the difference is always a whole number of 24-hour days. A daylight-saving transition in your business timezone cannot make a "days until" figure off by one, and there is no fractional-day rounding to get wrong [src: app/src/Permits.php:126].

The database is never asked for "now" — today flows in from the application in your business's timezone [src: app/src/Permits.php:10].

Together those mean a permit that expires in seven days says seven, in March and in October, on MySQL and on SQLite.

Expiry states

A permit's expiry is classified against three configurable windows — notice, warning and critical, defaulting to 60, 30 and 7 days [src: app/src/Permits.php:151]:

StateMeaning
noneNo expiry date set, or the stored value is not a date
expiredExpiry is before today
criticalZero to critical days away — "expires today" is critical
warningBetween critical and warning
noticeBetween warning and notice
okFurther out than notice

A permit expiring today is still valid through the day [src: app/src/Permits.php:141] — zero days remaining, not expired.

A mis-set settings row cannot invert the buckets

The three thresholds are clamped into a sane non-negative descending order before they are used [src: app/src/Permits.php:167], so entering a warning window smaller than the critical one cannot produce nonsense: the values are pulled back into order rather than obeyed literally.

⭐ Permit alerts that go out (3.2)

The dashboard is right; the people who need it are on site. Switch on Settings → Permit alerts by email [src: app/controllers/v32.php:9] and, once a day, through your own SMTP server, the office address gets one email listing [src: app/src/Alerts.php:51]:

  • permits in their notice, warning or critical window, and permits already expired (the same windows as the dashboard), worst first;
  • inspections booked for tomorrow;
  • overdue inspections (still pending, date passed);
  • active jobs with a live permit and no inspection booked.

Name a job lead and their email on a job and tick "also send each job's lead", and each lead gets the same email for their own jobs only [src: app/src/Alerts.php:121]. The rules [src: app/src/Alerts.php:186]:

  • it goes only when something is on it, at most once a day per person: the day and the address are claimed before the mail server is contacted, so two page loads never send it twice; a refused send gives its claim back and ends the run;
  • every email has its own stop link, signed for that address [src: app/src/Alerts.php:151]; opening it shows a button (so an email scanner that follows links changes nothing), and an address that pressed it is never written to again until you add it back in Settings;
  • Permit alerts (from Settings) shows exactly what each person would get today, and sends nothing [src: app/controllers/v32.php:27].

They go a few at a time when the dashboard opens; a daily cron can call /cron/alerts?t=<scheduled-backup token> [src: app/controllers/v32.php:41]. The same items are read-only at GET /api/alerts [src: app/controllers/v32.php:83].

Pull-by dates, AHJ comments, internal checks, jobs from Jobora (3.2)

  • Pull-by date. A permit can carry the date it has to be pulled by. Until it is issued (status applied, no issued date), it is "to be pulled within N days" inside your warning window, "to be pulled today", or "pull-by date passed", and those join the daily alerts as Permits to pull; once issued it reads "pulled" [src: app/src/PermitOps.php:28].
  • AHJ comments and corrections. On a permit's page, record what the plan reviewer or inspector asked for, when it came in, and a follow-up date. An open comment whose follow-up day has come joins the alerts as AHJ comments to follow up, naming the AHJ. Closing it takes a sentence on how it was addressed (the revised sheet, the resubmittal), and it is closed once [src: app/src/PermitOps.php:75]. The job page shows how many are open per permit.
  • Internal quality checks. Save named lists in Settings (a rough-in, a final) and add one to an inspection, or type checks one per line [src: app/src/PermitOps.php:138]. Each is ticked done, not applicable, or left open, with a note. Tomorrow's alert says how many are still open, and the job page shows "checks 3/5". They are your own readiness: the inspector's result is recorded as given, whatever the checks say.
  • Jobs from Jobora. Put your Jobora address and a key made in Jobora (API & agents) in Settings. Jobs → From Jobora lists Jobora's jobs that are not completed or canceled [src: app/src/PermitOps.php:191]. Tick the ones that need permits and each comes in once, with its number, title, client and site address; bringing it in again is refused [src: app/src/PermitOps.php:225]. Permora only reads Jobora. The key is kept out of the JSON backup.

Permit deposits: getting them back (3.3)

In most towns the deposit a permit leaves with the AHJ does not come back by itself. Someone has to request the final inspection, then ask for the money, and on a job that is finished and closed nobody is looking any more.

  • Record it on the permit. Edit a permit and fill in Deposit held by the AHJ, the day it was paid, and, when it comes back, Refunded on [src: app/controllers/app.php:486]. Permora never pays or collects it.
  • Record when the work finished. On the job, Work finished on is the day the work on site was done [src: app/controllers/app.php:343]. A deposit on a job whose work is not marked finished is "held" and is never chased.
  • Where each deposit stands [src: app/src/PermitOps.php:276]: - waiting until the chase day, which is N days after the work finished (30 by default; Settings → Permit alerts sets 1 to 365 [src: app/controllers/v32.php:16]); - request the final inspection once the chase day has come and no final has passed. A final is an inspection whose type contains "final" with the result pass [src: app/src/PermitOps.php:302]; - ask for the refund once the final has passed and the money has not come back; - refunded once the date is recorded.
  • The daily alert gains Permit deposits to get back, with the amount, the AHJ, the permit and what to do. It includes closed jobs, because that is when deposits get forgotten [src: app/src/Alerts.php:83]. A job's lead gets their own job's deposits. GET /api/alerts gains deposits_to_get_back [src: app/controllers/v32.php:91].
  • Deposits (in the menu) lists every deposit, the ones to get back first, with how many are still held and how much [src: app/controllers/v32.php:212]. - Mark refunded records the date, today or earlier, once [src: app/src/PermitOps.php:334]. - It needs the permit-edit right (coordinator or admin) [src: app/controllers/v32.php:222]; a viewer sees the list only. - The refund is written to the audit trail.

Inspections

An inspection is overdue only when it is still pending and its scheduled date is strictly before today [src: app/src/Permits.php:195].

Two consequences follow, and both are deliberate:

  • An inspection scheduled for today is "due today", not overdue [src: app/src/Permits.php:196].
  • An inspection that has a result — pass, fail or partial — is never overdue, regardless of when it was scheduled [src: app/src/Permits.php:197].

That second one matters: a failed inspection from last week is not an outstanding task, it is a completed inspection with a bad outcome. Conflating the two would fill an overdue list with work that has already happened.

An inspection is "upcoming" when it is pending with a scheduled date on or after today [src: app/src/Permits.php:208], which is what "this job has a next inspection scheduled" means.

Money

Permit fees are integer cents, summed in PHP with a final clamp and never in SQL — because a SQL SUM() over many integer rows can overflow under MySQL's strict mode [src: app/src/Permits.php:11].

Values are clamped to a bound that fits a MySQL integer with headroom [src: app/src/Permits.php:24].

Exporting

The CSV export carries a numeric-aware formula-injection guard [src: app/src/Permits.php:13], so a cell that would execute in a spreadsheet is defused while real numbers stay numbers.

Users and roles

Three roles, and note that the middle one is named for the job rather than generically: viewer, coordinator, admin [src: app/src/Auth.php:57].

A coordinator runs the work — schedules inspections, records results, attaches permit documents and inspection photographs — and cannot manage users or change settings [src: app/src/Auth.php:45].

Permissionviewercoordinatoradmin
.view permissions, report.viewyesyesyes
audit.viewyesyesyes
job.*, permit.*, inspection.*, attachment.*—yesyes
view.save—yesyes
users, settings——yes

Why settings are admin-only

The source gives a specific reason rather than a generic one, and it is the best justification for a settings lock in the catalog [src: app/src/Auth.php:50]:

The notice, warning and critical day windows live there, and those three numbers decide which permits the dashboard calls "expiring". Moving them changes what every screen in the product claims is urgent, which is an owner's decision rather than a scheduling one.

The audit trail is granted to a viewer explicitly — audit.view is named in the viewer's list rather than arriving through the wildcard [src: app/src/Auth.php:58]. A deliberate decision, not an accident of pattern matching.

The API and agent access

Seven tools are exposed to an agent, of which three write [src: app/controllers/v3.php:260]: listing permits, permit detail, upcoming inspections and an expiry report on the read side; creating a permit, scheduling an inspection and recording an inspection result on the write side.

⚠️ The agent endpoint reports an older version than the product

The REST interface, its OpenAPI document and the health check all report 3.1.1 [src: app/controllers/api.php:17], [src: app/controllers/api.php:223], [src: app/controllers/api.php:354].

The agent endpoint reports 3.0.0 [src: app/controllers/v3.php:259], and so does its transport probe [src: app/controllers/v3.php:519].

Nothing behaves differently because of it — the tools are the current ones — but an agent or a monitoring system reading the server version will be told the wrong number. If you are matching on it, match on the REST value. This has been raised against the product.

A bug the source records, worth reading if you build integrations

The specification key is app, not name, and the source explains what spelling it name cost: an undefined-array-key warning on every initialize, which with display errors on flushed output before the core could set its headers, and shipped a nameless server to the client [src: app/controllers/v3.php:253].

The lesson recorded alongside it is the useful part: the unit suite missed it because it built its own correct specification by hand instead of calling this function [src: app/controllers/v3.php:256].

A test that constructs its own input tests the code it wrote, not the code that ships.

What Permora does not do

It does not email anybody until you switch alerts on, and never anybody who pressed their stop link [src: app/src/Alerts.php:121].

It does not file a permit with anybody [src: app/src/Permits.php:16], collect or move money, sign anything, or give legal advice.

It does not treat a completed inspection as outstanding [src: app/src/Permits.php:197].

It does not lose or gain a day across a clock change [src: app/src/Permits.php:126].

It does not obey an inverted settings row [src: app/src/Permits.php:167].

It does not sum money in SQL [src: app/src/Permits.php:11].

Looking after your install (from 4.0)

Updates from inside Permora

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; Permora 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. Permora is unavailable for about a minute while this runs. The update replaces Permora'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 Permora 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 Permora 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: building jobs with permits and inspections in every state to try things on. You can remove it in one click. [src: app/install/index.php:128]. 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 jobs, permits and inspections? 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:87]. 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 Permora 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 Permora 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)/ { 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/permora/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy)/ { 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)

Permora 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 Permora 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 Permora'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 Permora 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 Permora'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 Permora 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 Permora 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 Permora'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.

← Back to Permora · Quickstart · API · Test run