Home›Privara›Manual
Privara · Manual

Privara Manual

Privara — User Manual

A register for data-subject requests, and the clock that runs on each one. Version 4.1.3 [src: app/controllers/api.php:18].

About this manual

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

One framing point before anything else. Privara records requests and computes dates from them. It does not decide whether you have complied with anything, and nothing in this manual is legal advice. What follows describes what the software calculates and where in the code it does so.

What Privara handles

Six kinds of request: access, deletion, rectification, portability, objection and restriction of processing [src: app/src/Dsar.php:31]. Request text is accepted up to 100,000 characters [src: app/src/Dsar.php:42].

A request can arrive through a public form on your own site [src: app/index.php:65], or be logged by a handler when it came in some other way — by post, by e-mail, or across a counter.

A subject who has submitted one can check its progress themselves, by reference [src: app/index.php:69], and a verification link confirms the request came from the address given [src: app/index.php:68].

The deadline, and how it is counted

This is the part of the product that does real work, and it is worth reading properly.

The statutory period is one calendar month [src: app/src/Dsar.php:89]. The counting is not, however, "add one month to today" — it follows the European Union's own rules for counting periods, which the source names and implements [src: app/src/Dsar.php:5].

The day of receipt does not count. The period begins the following day [src: app/src/Dsar.php:76].

A month-period ends on the same-numbered day of the last month, clamped to the month end where that day does not exist [src: app/src/Dsar.php:45]. A request received on 31 January produces a deadline in February that lands on the 28th or 29th, not in March.

A deadline falling on a Saturday, Sunday or public holiday runs to the end of the next working day [src: app/src/Dsar.php:59].

There is one further guard worth knowing about, because it protects against a subtle case. Clamping a month-end date can pull the computed end back onto or behind the date the request arrived; the code refuses to let the period end before the day after receipt [src: app/src/Dsar.php:76].

Why the counting rules matter rather than being pedantry

The source states the consequence of getting this wrong, and it is the reason the rules are implemented rather than approximated: counting from the received date without those provisions marks requests overdue up to three days early [src: app/src/Dsar.php:13].

Three days early is conservative, and it is still wrong in a register a controller might have to show a regulator. A deadline that is wrong in your own favor is still a deadline you cannot defend.

The extension

A complex request may be extended, once, to three months in total [src: app/src/Dsar.php:96].

The application enforces the "once" and refuses a second attempt with a plain explanation [src: app/controllers/app.php:237]. Every extension is written to the request's log with the new deadline [src: app/controllers/app.php:237].

That rule lives in exactly one place, deliberately. The source notes that the screen and the REST interface each used to carry their own copy, and that two copies of a statutory rule is one copy too many [src: app/controllers/app.php:230].

The verification email

When a request arrives through the public form, the requester is sent a link to confirm their address [src: app/src/helpers.php:100]. It goes through your own SMTP server whenever an SMTP host is set in Settings, and through the server's own mail() only when none is [src: app/src/helpers.php:69]. The sender is the SMTP "From" address, or the DPO address when that is empty, or the SMTP username when that is an address [src: app/src/helpers.php:55].

Whether it was sent or not is written to the request's own log. When it was not — no mail server, or the server refused it — the log says so and why, and the requester's confirmation page tells them the email could not be sent and gives the DPO address to write to instead [src: app/src/helpers.php:100].

Correspondence and evidence

Files can be attached to a request: a PDF, Word, OpenDocument or Excel file, a PNG or JPEG scan, a TXT, CSV, JSON or EML file, or a ZIP, up to 10 MB each [src: app/controllers/v3.php:649]. The file's own bytes decide its type, not its name; HTML and SVG are refused whatever they are called [src: app/controllers/v3.php:651]. If your server's PHP upload limit is lower than 10 MB, the page states the lower figure and a larger file is refused with that reason [src: app/controllers/v3.php:705]. Attachments are served only to a signed-in user, and every type except an image downloads rather than opening in the browser [src: app/controllers/v3.php:751].

Erasing a closed request's subject details also deletes its attached files from disk and blanks their names [src: app/controllers/v3.php:774].

GDPR and California's CCPA / CPRA

Each request carries a regime. GDPR / UK GDPR keeps the one-month clock described above. CCPA / CPRA (California) gives 45 calendar days from receipt, extendable once by 45 more (Cal. Civ. Code § 1798.130(a)(2)(A)), with no working-day roll [src: app/src/Dsar.php, deadlineFor(), extendedFor()]. Both CCPA windows are editable under Settings, with the statutory 45 as the default. Settings also sets the regime for new requests and whether the public form asks the requester which law applies; the form states both clocks when it does. A handler can change a request's regime while it is open: both deadlines are recomputed from the received date and the change is logged with the old and new dates. Which law applies to a requester is your determination; Privara applies the clock you choose.

The public pages in seven languages

The request form, the status page, the verification page and the verification email come in English, German, French, Spanish, Italian, Dutch and Polish. Each ships as code in app/lang/ [src: app/src/helpers.php, pub_lang_begin()]. Settings sets the default and ticks the extra languages to offer; the pages then show a language picker, and a requester whose browser asks for an offered language lands in it. The request records its language (shown on the request page), and the verification email is sent in it. The staff screens stay English. The translations were drafted for this release: have a native speaker read the pages in any language you offer.

Identity checks

The email link proves the requester controls an address. A handler can record further checks on a request (an ID document seen, a sign-in to their account, answers only they could know, a call-back to a number on file, a check in person, or another check with a note), each with who and when, in the request's timeline [src: app/controllers/v3.php, identity_check_add()]. Settings can ask for a minimum before a request is completed: none (the default, as before), one (the email link or a recorded check) or two. The screen, the REST API and the agent all refuse to complete a request short of that minimum, with the reason; refusing a request never needs checks. Which checks are proportionate is your decision. Erasing the subject clears the check notes.

The request form on your own website

List your website's address under Settings (for example https://www.example.com) and the settings page gives you a snippet to paste on your privacy page: an <iframe> of /request/embed. The embedded form is off until a site is listed, and only the listed sites can frame it (Content-Security-Policy frame-ancestors) [src: app/controllers/app.php, public_request_embed()]. A browser does not send this app's session cookie inside another site's frame, so the embedded form carries a signed token valid for two hours instead, and shows its thank-you in place. Everything else is the public form's own path: validation, the reference, the verification email, the notification and the webhook.

The systems checklist

A request is only answered once every system that holds the person's data has been searched, corrected or erased. Under Settings, list your systems, one per line: the CRM, the mailing tool, the accounts package, the shared drive, the backups [src: app/controllers/app.php, admin_settings_save]. Blank lines and repeats are dropped.

On a request, Add your systems loads that list as the request's checklist; a second press adds nothing twice. Add puts a one-off system on it. Each row has an owner (an active member or admin) and a Mark done button that records who, when and an optional note, and writes a line in the request's timeline [src: app/controllers/v3.php, _privara_task_set_done()]. The card's heading says what the request type asks of each system (for a deletion request, "erase"); that is a label, not advice on what to disclose or keep.

The deadline board shows each request's progress ("3 of 5 done"), the daily digest lists every open system under its request with the owner to chase, and Mark completed warns when systems are still open. It does not stop you completing: the checklist is your record, not a gate.

Chasing the owners (off by default). With Chase system owners switched on in Settings, each owner of a system still open on an open request gets one email a day listing those systems, with each request's reference, type and deadline and a link to it; never the requester's name or details, which stay in Privara [src: app/controllers/v3.php:558]. A system is chased once it has been open for the days you choose (2 by default); the email goes with the first dashboard visit of the day or the digest cron call, once per owner per day, and a refused send is written to the audit trail.

A closed request's checklist is frozen; reopen the request to change it. Erasing a subject's details clears the checklist notes with the rest of the free text and keeps the system names, owners and dates. The checklist is on the request in the REST API and the MCP tools, and POST /api/requests/{id}/tasks/{task}/done or the MCP tool complete_task ticks a system off through the same path as the button. Privara never connects to those systems itself.

Writing to the requester (3.3.0)

Write to the requester on a request starts from one of your templates, those for the request's type first, with {ref}, {name}, {type}, {deadline}, {received}, {org} and {dpo_email} filled in. Edit it, then Send the email: it goes to the requester's address through your own mail server, and the subject and text you sent are kept in the request's timeline [src: app/controllers/v3.php:1645]. A refused send is recorded as not sent, with the server's reason; the public demo sends nothing. Erasing the requester later clears the text with the rest of the timeline's free text. A template's bracketed example wording is not advice: read it through before sending.

The response record

Response record on a request downloads one page for whoever asks you to show the request was handled in time: the verdict first, answered on a date so many days before or after the deadline in force (the extension included), or still open with the days left or past [src: app/controllers/v3.php:1538]; then the received, verified, deadline, extension, status and closed dates, every system on its checklist with its owner and who marked it done and when, the identity checks recorded, and the timeline. Each download is written to the audit trail with the SHA-256 of the file handed out. GET /api/requests/{id} carries the same verdict as response. Dates are by the register's own clock; the record is not a legal opinion.

The deadline digest

The daily digest of what is overdue and what falls due soon is sent with the first dashboard visit of the day once it is switched on and SMTP is set — no cron job is needed [src: app/controllers/v3.php:471]. A daily cron call to the digest URL still works for days nobody signs in. A mail server that refuses is retried at most once an hour, and the failure is written to the audit trail [src: app/controllers/v3.php:471].

Status: what is stored and what is worked out

Privara keeps five stored statuses — new, verifying, in progress, completed and refused [src: app/src/Dsar.php:33] — and displays six [src: app/src/Dsar.php:35].

The sixth is overdue, and it is never stored. It is derived whenever status is asked for, by comparing today against the request's effective deadline [src: app/src/Dsar.php:107].

The precedence is deliberate: refused and completed win first, because a closed request cannot become overdue afterward; then overdue; then whatever the stored status was [src: app/src/Dsar.php:104].

"Effective deadline" means the extended one where an extension was granted, and the original otherwise [src: app/src/Dsar.php:112].

Because overdue is derived rather than written, a register cannot drift into claiming a request is on time after its date has passed.

References and verification links

Each request gets a short reference for humans and a long token for machines, and they are built differently on purpose.

The reference is eight characters from an alphabet that omits the look-alikes — no zero, O, one or I [src: app/src/Dsar.php:39]. It is meant to be read down a telephone.

The verification token is thirty-two random bytes in hexadecimal [src: app/src/Dsar.php:134]. It is meant to be unguessable.

Both are checked for uniqueness as they are issued and both throw rather than return a duplicate if a free value cannot be found [src: app/src/Dsar.php:122].

Users, roles and what each may do

Three roles [src: app/controllers/v3.php:49].

Permissionviewermemberadmin
request.viewyesyesyes
audit.viewyesyesyes
request.handle—yesyes
request.attach—yesyes
view.save—yesyes
everything else——yes

The source names the intent: a viewer holds an auditor's seat and changes nothing, a member is the person who actually handles requests, and settings, templates, erasure and the team are the data protection officer's own [src: app/controllers/v3.php:49].

Exporting the register

The register exports as CSV or JSON [src: app/src/Dsar.php:217].

The API and agent access

Machine access uses a key [src: app/index.php:91]. Requests can be listed and fetched, a status advanced, and an extension applied [src: app/index.php:92].

Five tools are exposed to an agent, of which two write [src: app/controllers/v3.php:49]:

ToolWrites?What it does
list_requestsnoRequests, filterable by derived status including overdue
request_detailnoOne request with its clocks and its full action log
deadlines_reportnoWhat is overdue and what falls due within N days
create_requestyesLogs a request that arrived by another channel
advance_statusyesMoves a request through the workflow

advance_status accepts only the stored statuses, so an agent cannot invent one [src: app/controllers/v3.php:49].

The import's dry run applies the same checks the import does, row by row, and writes nothing: a row the import would skip (a template with an unknown request type) is listed as skipped in the dry-run report, not counted ready.

What Privara does not do

It does not tell you whether you have complied. It records what arrived, computes the dates the regulation's counting rules produce, and shows you what is overdue. Whether a response was adequate, whether a refusal was lawful, and whether an extension was justified are all judgements it does not make and does not store an opinion about.

Public holidays are supported but not supplied. The working-day roll accepts a list of holidays, in either a recurring MM-DD form or a specific YYYY-MM-DD one [src: app/src/Dsar.php:56]. Neither the request creation path nor the extension path passes one [src: app/src/Dsar.php:76], and the source records that as deliberate [src: app/controllers/app.php:230]. In practice that means weekends are rolled off and national holidays are not — so a deadline landing on a public holiday will be shown as falling that day. If your jurisdiction's holidays matter to your register, that is a gap to close before relying on the dates.

It is not legal advice, and the wording of your notices, your lawful bases and your retention rules are yours. Privara counts days and keeps a trail.

Looking after your install (from 4.0)

Updates from inside Privara

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; Privara 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. Privara is unavailable for about a minute while this runs. The update replaces Privara'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 Privara 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 Privara 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 privacy requests across every status, with reply templates to try things on. You can remove it in one click. [src: app/install/index.php:133]. 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 and templates? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the backup page (/restore, linked from Settings), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:98]. 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 Privara 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 Privara 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/privara/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)

Privara 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 Privara 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 Privara'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 Privara from the console starts a fresh session [src: app/index.php:55].

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 Privara'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 Privara 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]. The embeddable request form (/request/embed) still follows the list of sites in Settings [src: app/controllers/app.php:118]. Any other Privara page framed inside another site of your own 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 Privara'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 Privara · Quickstart · API · Test run