Reference¶
Everything the app knows of, to look up: the settings, the keys in which Nextcloud keeps them, the routes, the versions that the tests cover, where data goes, and what stays when the app goes.
Settings¶
The settings are under Administration settings → Paperless Unified Search, for administrators only, and hold for every user of the Nextcloud. Test connection and save stores them only once Paperless answers; it and Disconnect ask for the password when it wasn't confirmed in the last 30 minutes.
| Setting | What it does | Default | App config key |
|---|---|---|---|
| Paperless URL | The address of Paperless, with a path when Paperless runs under one, such as https://paperless.example.com |
— | paperless_url |
| Paperless runs in my local network | Lets Nextcloud reach this URL, and no other, at an address of the local network, without redirects (details) | Off | allow_local |
| Paperless API token | The token of the Paperless account of the search; blank keeps the stored one, for the same server | — | None: in Nextcloud's credentials manager, as paperless_unified_search.api-token |
| Archive account | The Nextcloud account whose files stand for the documents; blank takes the account of Paperless Sync | The account of Paperless Sync | archive_owner |
| Always include Paperless in global search | Asks Paperless in every search of every user, without Search connected services; reload Nextcloud after a change | Off | always_search |
Disconnect deletes the URL, the token, the switches, the archive account, the version of Paperless and the history of the problems. Clear history deletes only the history.
App configuration¶
Nextcloud keeps the settings in its app configuration, which occ reads:
occ config:list paperless_unified_search
occ config:app:get paperless_unified_search paperless_url
| Key | Holds |
|---|---|
paperless_url |
The URL of Paperless, as the settings stored it: without a slash at its end, with the host in its ASCII form |
allow_local |
Whether Paperless runs in the local network |
archive_owner |
The ID of the archive account, as Nextcloud spells it; missing when the settings take the account of Paperless Sync |
always_search |
Whether every search asks Paperless |
paperless_version, paperless_api_version |
The versions of Paperless and of its API, such as 3.3.0 and 10, as its last answer named them |
diagnostics |
The history of the problems of the search, as JSON; a lazy key, which Nextcloud loads only when it is read |
diagnostics_pending |
Whether a failure in the history still waits for a search that works |
The account of Paperless Sync is occ config:app:get paperless_sync target_user.
Change the settings in the administration settings, which test the connection first. occ config:app:set can still turn a switch on or off, such as occ config:app:set paperless_unified_search always_search --value=true --type=boolean, which every user's next reload takes. Don't change paperless_url that way: the stored token would follow the URL to the new server, which the settings prevent by asking for the token again.
Set up and check it with occ¶
Four commands of occ do without a browser what the settings do, and more: they set the app up for Ansible, Docker and other scripts, test it for monitoring, and say why a user doesn't find a document.
| Command | What it does | Exit code |
|---|---|---|
paperless_unified_search:setup |
Tests the connection with the URL and the token, then saves the settings, as Test connection and save does | 0 once saved, 1 when Paperless doesn't answer or a setting is wrong, 2 for a wrong call, such as a missing --url or an empty token source |
paperless_unified_search:test |
Tests the connection with the stored settings and names the version of Paperless | 0 when Paperless answers, 1 otherwise |
paperless_unified_search:search <user> <term> |
Searches as the user and says for each document whether it shows to them, with --explain why not |
0 once Paperless answered |
paperless_unified_search:check |
Tests the connection and the archive account, and counts the documents of Paperless that have a file | 0 when the search can show documents, 1 otherwise |
Set up the app¶
The token never goes on the command line, where the history of the shell and the list of processes would show it to others: setup reads it from the standard input with --token-stdin, or from an environment variable with --token-env=NAME. An option that you leave out keeps its stored value, and without a token the stored one stays, but only for the server it was saved for, as on the settings page.
| Option | Sets |
|---|---|
--url=URL |
The address of Paperless, such as https://paperless.example.com; required |
--token-stdin, --token-env=NAME |
The API token, from the first line of the standard input or from the environment variable NAME |
--archive-account=ID |
The account that owns the synchronized files; empty for the account of Paperless Sync |
--allow-local, --no-allow-local |
Paperless runs in my local network |
--always-search, --no-always-search |
Always include Paperless in global search |
On a server, as the user of the web server:
printf '%s\n' "$PAPERLESS_TOKEN" | sudo -u www-data php occ paperless_unified_search:setup \
--url=https://paperless.example.com --archive-account=paperless --token-stdin
With Docker Compose and the image of Nextcloud, from a file that only you can read:
docker compose exec -T --user www-data nextcloud php occ paperless_unified_search:setup \
--url=http://paperless:8000 --archive-account=paperless --allow-local --token-stdin \
< paperless-token.txt
With Ansible, from its vault, and without the token in its output:
- name: Connect Paperless Unified Search to Paperless-ngx
ansible.builtin.command:
argv: [php, occ, paperless_unified_search:setup, --url=https://paperless.example.com,
--archive-account=paperless, --token-stdin]
chdir: /var/www/nextcloud
stdin: "{{ vault_paperless_token }}"
become: true
become_user: www-data
no_log: true
Explain a hidden document¶
The search leaves out every document that has no file of the archive account that the searching user can open, without a trace: twelve documents that jamie may not open look exactly like none. search lists the documents that Paperless ranks first, 10 by default and up to 50 with --limit, with whether each shows to the user, and --explain names the reason of each one that doesn't:
$ occ paperless_unified_search:search jamie invoice --limit=5 --explain
+-----+-----------------------------------+------------+--------------------------------------------------------------------------------+
| ID | Title | Verdict | File |
+-----+-----------------------------------+------------+--------------------------------------------------------------------------------+
| 412 | Electricity invoice - August 2026 | shown | /Paperless/2026/Electricity invoice - August 2026 [P412].pdf |
| 389 | Home insurance renewal | not shared | /Private/Home insurance renewal [P389].pdf of paperless, not shared with jamie |
| 371 | Internet invoice - July 2026 | shown | /Paperless/2026/Internet invoice - July 2026 [P371].pdf |
| 356 | Water bill 2026 | no file | |
| 344 | Old invoice | not owned | /Scans/Old invoice [P344].pdf of jamie |
+-----+-----------------------------------+------------+--------------------------------------------------------------------------------+
Paperless found 12 document(s) for "invoice"; 2 of the 5 above show to jamie.
Not shared (389): the archive account paperless has the file, but hasn't shared it with jamie. Share the file, or a folder that holds it, read-only with jamie.
Not owned (344): jamie sees a file with the marker of the document, but only the files of the archive account paperless stand for documents.
No file (356): no file of the archive account paperless has the marker of the document, such as [P356], in its name. The synchronization hasn't written it yet, or names files without it.
---
config:
flowchart:
wrappingWidth: 400
nodeSpacing: 30
---
flowchart TB
accTitle: Why a document does not show
accDescr: Paperless finds document 389 when jamie searches, and occ paperless_unified_search:search jamie invoice --explain asks three questions in turn. Does jamie see a file of the archive account with [P389] in its name? Then the document shows. Otherwise, does the archive account have such a file in its own folders? Then it is not shared with jamie, and sharing the file or its folder read-only with jamie shows it. Otherwise, does jamie see a file with the marker of another account? Then it is not owned, since only files of the archive account stand for documents. Otherwise there is no file, and the synchronization has yet to write one with the marker in its name.
hit["Paperless finds document 389<br>when jamie searches"] --> visible{"Does jamie see a file of<br>the archive account<br>with [P389] in its name?"}
visible -- "yes" --> shown["shown"]
visible -- "no" --> archived{"Does the archive account<br>have such a file in its<br>own folders?"}
archived -- "yes" --> unshared["not shared: share the<br>file or its folder<br>read-only with jamie"]
archived -- "no" --> other{"Does jamie see a file<br>with [P389] of another<br>account?"}
other -- "yes" --> unowned["not owned: only files<br>of the archive account<br>stand for documents"]
other -- "no" --> missing["no file: let the<br>synchronization write<br>one with the marker"]
classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
classDef no fill:#dc26262e,stroke:#dc2626,stroke-width:2px
class shown yes
class unshared,unowned,missing no
Without --explain, search decides as the search itself does, with one search of the file cache, and calls every document that doesn't show hidden; with it, a search of the file cache follows for each further question, for the documents that are still unexplained. --json writes the documents for scripts, each with its id, title, verdict, path and owner.
Check the whole set-up¶
check tests the connection and the archive account, and compares the documents that the account of the API token sees in Paperless with the files of the archive account whose names carry their markers:
$ occ paperless_unified_search:check
✓ Paperless 3.3.0 (API 10) answers at https://paperless.example.com.
✓ The archive account is paperless, the account of Paperless Sync.
! Coverage: 1,180 of 1,234 Paperless document(s) have a file of the archive account. Without one, among others: 17, 18, 19, 20, 23, 31, 40, 41, 42, 57.
Documents without a file are no failure, as long as the search can show others: the synchronization may not have written them yet, or leaves some out on purpose. When not one document has a file, as with an archive account that owns none of the synchronized files, check exits with 1 and says what to correct. search --explain with a term of such a document says why it doesn't show.
In the administration overview¶
Administration settings → Overview checks the app among the other setup checks of Nextcloud, and so does occ setupchecks. It warns when the search shows no Paperless documents for a reason that an administrator corrects, and links to the settings of the app: a token that Nextcloud can no longer decrypt, no archive account or one that doesn't exist, or a search that failed with none working since. Otherwise it names the Paperless that the app is connected to, with the version of its last answer. It reads only what the app stored and sends no request to Paperless, so the overview opens as fast as without it.
The routes of the settings¶
The settings page saves through four routes of the app, which only administrators may call, each with Nextcloud's CSRF token. A script calls them with the login of an administrator and the header OCS-APIRequest: true, as the end-to-end tests do; /index.php goes before /apps where Nextcloud runs without pretty URLs.
| Route | Does |
|---|---|
POST /apps/paperless_unified_search/settings |
Tests the connection and saves, with the parameters url, token, archiveOwner, allowLocal and alwaysSearch; asks for the password like Test connection and save |
DELETE /apps/paperless_unified_search/settings |
Disconnect: deletes the settings, the token and the history; asks for the password |
DELETE /apps/paperless_unified_search/settings/diagnostics |
Clear history |
POST /apps/paperless_unified_search/settings/try |
Try a search: searches with the parameter term as the account user and says for each document whether it shows and why not, with its ID and title alone; the log records which administrator tried a search as whom, without the term |
curl -u admin -H 'OCS-APIRequest: true' -H 'Accept: application/json' \
--data-urlencode 'url=https://paperless.example.com' \
--data-urlencode 'archiveOwner=paperless' \
--data-urlencode "token@$HOME/.paperless-token" \
'https://cloud.example.com/apps/paperless_unified_search/settings'
token@file hands the token over in a file, which keeps it out of the history of the shell and the list of processes. A saved setting answers HTTP 200 with the settings as JSON: url, tokenConfigured, alwaysSearch, archiveOwner, syncAccount, tokenUnreadable, archiveOwnerMissing, allowLocal, paperlessVersion and paperlessApiVersion, never the token. A refusal answers HTTP 400, or 500 when something after the test failed, with the message to show in the language of the administrator and a code:
| Code | Means |
|---|---|
invalid_url, unsupported_scheme, url_extras, host_not_ascii |
The URL isn't one, isn't HTTP or HTTPS, has credentials, a query or a fragment, or a host that isn't in its ASCII form |
token_required, token_for_other_server |
No token is stored, or the stored one is for another server |
unknown_archive_account |
The archive account doesn't exist |
unauthorized |
Paperless refused the token, with HTTP 401 or 403 |
not_found |
HTTP 404: the path of the URL is wrong |
redirect |
The URL forwards, and with the local network Nextcloud follows no redirect |
http |
Another status, such as an error of Paperless with 5xx |
unsupported_version |
HTTP 406: Paperless is older than 2.16 and doesn't speak version 9 of its API; updating Paperless-ngx fixes it |
not_paperless |
The answer isn't a list of documents of Paperless-ngx |
local_address |
Nextcloud refused the address of the local network |
tls, tls_handshake |
Nextcloud doesn't trust the certificate, or couldn't set up an encrypted connection |
unknown_host, timeout, unreachable |
The host doesn't resolve, Paperless didn't answer in time, or the connection failed |
internal |
Something after the test failed, such as the database; the log of Nextcloud has it |
Troubleshooting says what to do about each.
The search and the smart picker¶
| ID | Where | |
|---|---|---|
| The search provider | paperless_unified_search_documents |
Paperless documents in Nextcloud's search, and GET /ocs/v2.php/search/providers/paperless_unified_search_documents/search?term=… in its OCS API, with limit (5 by default, up to 50) and cursor |
| The provider of the smart picker | paperless_unified_search-documents |
Paperless documents in the smart picker of Talk, Text, Deck and Mail |
Use it from scripts and AI assistants shows the answer of the API.
Requests to Paperless¶
Every request of the app to Paperless is one GET of <URL>/api/documents/, from the Nextcloud server:
| Headers | Authorization: Token …, Accept: application/json and User-Agent: Nextcloud-Paperless-Unified-Search/<version> |
| A search | query with the term, page, page_size=25 and fields=id,title,created,correspondent,document_type,__search_hit__; on Paperless 3, a query that it can't parse yet once more with text in place of query |
| The test of the connection | page_size=1 and fields=id |
| Time | 3 seconds to connect, 10 seconds for the answer |
| A second try | Only for a request that failed within 3.5 seconds, before it had a connection, and whose address Nextcloud didn't refuse |
| Redirects | Without Paperless runs in my local network, up to 5, never to an address of the local network; Nextcloud leaves the token out of a redirect to another scheme, host or port, which Paperless then refuses, and keeps it for one within the same server. With the switch, none |
Compatibility¶
| Nextcloud 33 | Nextcloud 34 | Nextcloud 35 | |
|---|---|---|---|
| Paperless-ngx 3.x (API 9) | End-to-end tests | End-to-end tests | End-to-end tests |
| Paperless-ngx 2.16 to 2.20 (API 9) | Unit tests | Unit tests | Unit tests |
- Nextcloud: the end-to-end tests run the app in the current release of each version that
appinfo/info.xmlsupports, in every pull request that changes the app and on every change ofmain, and every Monday also in the coming version of Nextcloud, its beta or release candidate. - Paperless-ngx: the end-to-end tests ask a stand-in of Paperless that answers as Paperless-ngx 3.3.0 does: its pages, its fields, its headers of the version, its excerpts and its refusal of a query that is still being typed. The unit tests check the answers of 2.20 and 3.3, such as that only Paperless 3 gets the simple mode of its search, which Paperless 2 doesn't know. Every request names version 9 of the API, which Paperless-ngx 2.16 and newer and every 3.x speak, so a new release of Paperless can't change the results unnoticed; contract tests check the app every week against real Paperless-ngx, the last release of 2.20 and the newest of 3.x. An older Paperless answers HTTP 406, and the test of the connection says so.
- PHP: 8.2 or newer, as Nextcloud supports it; the unit tests run on 8.2.
Data flows¶
| From | To | What | When |
|---|---|---|---|
| The browser or an app of the user | Nextcloud | The search term | Whenever the user searches |
| Nextcloud | Paperless | The term, the page and the fields to answer with, and the API token | With Search connected services on, after choosing Paperless documents in the smart picker, through the API, or always when Paperless is trusted |
| Paperless | Nextcloud | Of each document that the account of the token may view: its ID, title, date, the IDs of its correspondent and its type, and the excerpt around the term; the versions of Paperless and of its API | In every answer |
| Nextcloud | The browser or an app of the user | Of each document whose file the user can open: its title, date and excerpt, and the link, the ID and the path of its file | In every answer |
---
config:
sequence:
wrap: true
width: 120
actorMargin: 10
---
sequenceDiagram
accTitle: The data flow of a search
accDescr: A user searches for invoice. The unified search passes the term, the cursor, the limit, the date filter and the user to the search provider. Without a term, an archive account or a connection, the provider answers with no results and doesn't ask Paperless. Otherwise, until the page of results is full, the Paperless client asks Paperless-ngx with the token for the page of 25 hits that holds the next hit, within the days of the date filter, and only for the fields that a result shows, and Paperless answers with the ID, the title, the date, the IDs of the correspondent and the type and the excerpt of documents 412, 389, 371 and more, and whether a next page exists. One call of the file locator covers all documents of that page and returns for each a file of the archive account with its marker, or none. For the documents with a file, the names ask Paperless, through the client, for the correspondents and the types that the cache doesn't hold, with one request per kind, and keep their names for ten minutes. This repeats for at most 4 pages of Paperless or 3 seconds. The provider answers with an entry for every document with a file, up to the limit, with the preview of the file or the icon of its type, the title, the date in the language of the user, the correspondent, the type and the excerpt, and the cursor of the first hit that it didn't go through.
actor User
participant Search as Unified search
participant Provider as Search provider
participant Client as Paperless client
participant Paperless as Paperless-ngx
participant Locator as File locator
participant Names as Names
User->>Search: invoice
Search->>Provider: term, cursor, limit,<br>date filter and user
alt no term, no archive account or no connection
Provider-->>Search: no results, without asking Paperless
else
loop until the page of results is full, for at most 4 pages of Paperless or 3 seconds
Provider->>Client: term, the days of the date filter,<br>the page of 25 hits with the next hit
Client->>Paperless: GET /api/documents/<br>?query=invoice&page=1<br>&page_size=25&fields=…<br>&created__date__gte=…,<br>with the token
Paperless-->>Client: ID, title, date, correspondent,<br>type and excerpt of documents<br>412, 389, 371, …, and whether<br>a next page exists
Provider->>Locator: the documents of the page,<br>user, archive account
Locator-->>Provider: per document a file of the<br>archive account with its marker,<br>or none
Provider->>Names: the correspondents and types<br>of the documents with a file
Names->>Paperless: through the client, for the IDs<br>that the cache lacks: GET<br>/api/correspondents/?id__in=3,5<br>and /api/document_types/?id__in=1,2
Paperless-->>Names: their names, which the cache<br>keeps for ten minutes
Names-->>Provider: the names
end
Provider-->>Search: an entry for every document with a file, up to the limit: preview or icon, title, date, correspondent, type and excerpt, and the cursor of the next hit
end
Nextcloud stores the settings in its app configuration, the token encrypted in its credentials manager, and the history of the latest 20 problems, without the term, the token and the query of any URL. Nothing of the documents is stored. Architecture explains every step, and the security design what protects each of them.
Security and privacy¶
- The Paperless API token is stored only in Nextcloud's server-side credentials manager.
- The token is never returned to browser JavaScript or rendered into HTML.
- The token is sent only to the Paperless server it was saved for; a URL with another scheme, host or port needs it entered again.
- Saving the settings and disconnecting ask for the administrator's password when it wasn't confirmed in the last 30 minutes.
occtakes the token from its standard input or an environment variable, never from the command line, where the history of the shell and the list of processes would show it.- Nextcloud's protection against addresses of the local network stays on for every app. Paperless runs in my local network lifts it only for the requests of this app to the Paperless URL of the settings, which follow no redirect.
- Search results are filtered through the current user's Nextcloud filesystem view.
- The settings show administrators the history of the problems of the search without the API token, the search term or the query of any URL.
- Try a search shows administrators which documents a user finds for a term, by their IDs and titles alone, without their content, an excerpt or their files; the audit log of the app Auditing / Logging (
admin_audit) records which administrator tried a search as which user, whatever the log level, and so does the log of Nextcloud at level info, without the term. - Search terms are sent server-to-server only when connected-services search or trusted-service mode is enabled, after the user chose Paperless documents in the smart picker, or when a client asks this search by its ID through the API.
- A link that the smart picker inserts is the link of the file in Nextcloud, which shows its preview only to readers who can open the file.
- Administrators can explicitly trust the configured Paperless server to include it automatically in every user's global searches.
- No deployment credentials, private hostnames, internal addresses, or instance configuration belong in this repository.
- Gitleaks scans every push and pull request.
- Dependency Review blocks newly introduced vulnerable or unapproved dependencies.
- Psalm analyzes the PHP code and CodeQL scans the JavaScript on every pull request.
- Every release includes an SPDX SBOM, a detached signature, and public Sigstore build provenance.
- OpenSSF Scorecard audits the repository's supply-chain security every week.
See SECURITY.md for reporting security issues.
The log of Nextcloud¶
The app writes to the log of Nextcloud with app set to paperless_unified_search, never with the term or the token:
| Level | Message | Means |
|---|---|---|
| Warning | The test of the connection to Paperless failed: reason (error) | Test connection and save failed; the reason is the code of the routes above |
| Error | The settings of Paperless Unified Search could not be saved (error) | Something after the test failed, such as the database |
| Warning | Paperless unified search failed (error) | A search failed; Search problems has it too |
| Warning | Paperless unified search left out n result(s) it could not show (error) | Results whose files failed to answer were left out |
| Warning | Paperless unified search skipped n file(s) it could not read (error) | Files of the archive that Nextcloud couldn't read |
| Warning | Paperless unified search looks for the files of each document on its own … | A version of Nextcloud whose query of the file cache the app doesn't know yet: the search still works, with one query for each document instead of one for each page |
| Warning | The stored Paperless API token can't be decrypted … | The secret of config.php changed: enter the token again |
| Debug | Paperless rejected the query of a search with HTTP status | A query still being typed, or a page past the last one: no problem |
Uninstall¶
- Disconnect in the settings. It deletes the URL, the token, the switches, the archive account, the version of Paperless and the history of the problems; only
diagnostics_pendingstays, with the keys that Nextcloud keeps of every app, such asenabledandinstalled_version. - Remove the app:
occ app:remove paperless_unified_searchdisables it and deletes its code; or Apps → Disable and Remove in the browser. Nextcloud keeps the app configuration of a removed app:occ config:list paperless_unified_searchlists what is left, andocc config:app:delete paperless_unified_search <key>deletes a key. - In Paperless, delete the API token, or the account of the search: a token that Nextcloud forgot still works until Paperless forgets it too.
The files of the archive and their shares stay as they are: they belong to the archive account and to the synchronization, not to this app. Without Disconnect, the encrypted token stays in the credentials manager of Nextcloud, under paperless_unified_search.api-token, until the app is installed again and disconnected.