Home›Tokora›Manual
Tokora · Manual

Tokora Manual

Tokora — User Manual

A waiting queue: numbered tokens, counters, a kiosk and a display board. Version 4.1.3 [src: app/src/App.php:8].

About this manual

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

A waiting queue — numbered tokens, counters and call state [src: app/src/Queue.php:11].

It never touches money: no prices, no payments, no cards, no crypto, and the source notes there is no monetary field anywhere in the product [src: app/src/Queue.php:12].

Numbering, and the daily reset with no cron

Ticket numbers are simply the highest issued so far plus one, scoped to one service on one service day [src: app/src/Queue.php:101].

That scoping is the daily reset, and the source explains why it is the whole of it: a fresh service day has no prior numbers, so the sequence restarts at 1 — no cron, no counter to roll over, and recomputing is idempotent [src: app/src/Queue.php:104].

Nothing has to run at midnight. Nothing can fail to run at midnight.

Carrying numbers on from day to day (3.2)

A desk whose visitors come back can switch on Settings → Carry numbers on from day to day. Each service then continues from its highest number ever issued, so A-058 yesterday is followed by A-059 this morning and a number is never reused between days [src: app/controllers/app.php:70]. A ticket transferred to another service takes its internal number from the same rule, and keeps the label it was issued with. It is off by default. Switched off again, numbering starts at 1 the next morning, as before.

Two kiosks at once cannot issue the same number

The number is chosen as a maximum plus one, which on its own would race. The database makes that impossible: a unique index means a concurrent double-issue cannot be committed, so on the rare collision the issuer recomputes and retries [src: app/controllers/app.php:80].

The retry is bounded at six attempts and only catches integrity violations — any other database error is re-thrown rather than swallowed [src: app/controllers/app.php:109].

One writer, so a ticket cannot be malformed

Token issuing happens in a single function, and two things are minted there for a stated reason.

The visitor's follow link is created in the one writer so a ticket cannot exist without one — because "a ticket with no link is a ticket nobody can follow, and the kiosk would have nothing to print" [src: app/controllers/app.php:94].

The phone number is normalized there too, so a number that cannot be dialled is stored as nothing rather than as junk the sender would trip over later [src: app/controllers/app.php:100].

Both are the same idea: fix it once, at the only place a row is created.

Calling the next token

First in, first out. The earliest issued token wins, with ties broken by issue order [src: app/src/Queue.php:116].

A counter bound to a service only draws from that service's queue; a general-purpose counter takes the single oldest waiter across all services [src: app/src/Queue.php:118].

"N people ahead of you" uses the same ordering

The figure shown on a visitor's ticket page counts the waiting tokens ahead of theirs in exactly the order the caller uses [src: app/src/Queue.php:142].

That consistency is the point. A queue product whose displayed position disagreed with its own call order would be worse than having no figure at all.

Status, and what a recall is not

Four statuses, with waiting the issued default and served terminal [src: app/src/Queue.php:24].

The legal transitions [src: app/src/Queue.php:27]:

FromMay move to
waitingcalled
calledserved, no_show, or back to waiting — returning a mis-call to the queue
no_showcalled, or waiting — recall an absentee directly, or requeue them
servednothing — terminal

A recall is not a transition. Re-announcing a token that is already called leaves the status unchanged and only refreshes the call time [src: app/src/Queue.php:32].

That distinction matters for your statistics: shouting a number again does not create a second call event.

Waiting times

Wait is measured from issue to call [src: app/src/Queue.php:161]. It returns nothing when either timestamp is missing or unparseable, and zero rather than a negative number if a call somehow precedes an issue [src: app/src/Queue.php:163].

Averages and per-day statistics are computed from those [src: app/src/Queue.php:177], [src: app/src/Queue.php:272].

Estimated wait (3.2.0). The ticket page and the "you are next" text show an estimate for that ticket: (people ahead + 1) × the average gap between today's calls for its service, from the last ten gaps, once three tickets have been called [src: app/src/Queue.php:215], [src: app/controllers/app.php:237].

The board's sound and QR (3.2.0)

Settings → The board's sound: a chime, a voice, the voice's language (a tag such as en-GB, fr-FR or pl-PL) and the sentence it says, with {token} said as "A 14" and {counter} as the counter's name. The board announces each new call once and a recall again; it never repeats what was on screen when it loaded. Sound starts after one tap on the board (browsers require it), and a board with sound keeps its page open rather than reloading, polling for changes instead.

Settings → Show a QR code on the board puts a code in the corner that opens the kiosk on a visitor's phone. A ticket page opened on a wide screen (the lobby kiosk) shows a QR of itself.

The texts in your own words (3.3.0)

Under Settings → Text alerts, each of the two texts ("you are next" and "go to the counter") has a box for your own words. Leave it empty and Tokora's words are sent, as before. Your words go to visitors in your language; a visitor who chose another language at the kiosk keeps Tokora's words in theirs [src: app/controllers/app.php:187].

Four placeholders are filled in: {label} is the visitor's number, {counter} the counter's name, {business} your name and {eta} "(in about 6 min)". {eta} stays empty until the line's pace is known, so a text never promises a wait of nothing [src: app/src/Notify.php:60]. Each text is kept to one line of at most 320 characters (two text messages), and words without {label} are refused, because a queue text that does not say whose turn it is helps nobody [src: app/src/Notify.php:42].

Visitor languages (3.2.0)

Settings → Visitor languages: a default language and the others the kiosk offers, from English, French, German, Spanish and Polish [src: app/src/I18n.php:19]. The default is what the board shows and what a ticket without a choice reads in. With more than one language offered, the kiosk shows a link for each (/kiosk?lang=fr; the choice lives in the link, so the next person at a shared kiosk starts afresh) and the ticket page shows a button for each [src: app/controllers/app.php:134]. The visitor's choice is stored on the ticket (tokens.lang, empty when it is the default) and used for their ticket page, the messages shown there and their texts [src: app/src/I18n.php:89], [src: app/src/Notify.php:55]. A language not offered falls back to the default; on the API it is refused with a 422 [src: app/controllers/api.php:53].

The board's spoken sentence, when you leave it empty, is said in the voice's language if Tokora has it (an fr-FR voice says "Numéro A 14, présentez-vous : Guichet 2.") [src: app/src/Queue.php:250]. Your own sentence is always said as written.

Translated: the kiosk, the ticket page, the not-found page, the board (including its live updates) and the visitor texts. Not translated: staff screens, and anything you type (service and counter names, the board notice, desk messages).

The public kiosk

The kiosk is rate-limited per address — fifteen issues in five minutes by default [src: app/controllers/app.php:119] — and carries a honeypot field [src: app/src/Queue.php:373].

Users and roles

Tokora's viewer role is granted *.view together with audit.view [src: app/src/Auth.php:27], so a viewer can read the audit trail — and because audit.view is named in the list rather than left to the wildcard, that is a deliberate decision rather than a side effect.

The API and agent access

Six tools are exposed to an agent, of which two write [src: app/controllers/v3.php:176]: listing services, queue status, a ticket's status and daily statistics on the read side; issuing a ticket and calling the next one on the write side.

The agent endpoint reports the same version as the rest of the product [src: app/controllers/v3.php:175].

An agent issuing a ticket goes through the same single writer a kiosk does, so it cannot produce a ticket without a follow link or with an undiallable number [src: app/controllers/app.php:94].

Data retention, and one visitor by number (4.1.1)

A ticket is a number and Tokora stores no name. Three fields can still identify somebody: the IP address a ticket was issued from, a staff note, and the mobile number a visitor typed for text alerts. Data retention (admin only) clears all three, with the ticket's messages, on every ticket older than the window you set, while the counts and wait times stay.

When one visitor asks what you hold, or to be erased, type their mobile number on the same page. Download what is held writes every ticket that carries the number, with its messages, as JSON. Erase this visitor clears the number, the IP address, the note, the SMS provider's error and the messages on each of those tickets [src: app/controllers/v3.php:872]. The number is matched the way the kiosk stores it, so spaces and dashes do not matter. It is refused while one of their tickets is still waiting or called, because the desk may still need to text them [src: app/controllers/v3.php:906]. The audit trail records how many tickets an erase touched, never the number.

What Tokora does not do

It does not handle money [src: app/src/Queue.php:12]. There is no monetary field in the product at all.

It does not need a cron job [src: app/src/Queue.php:104]. The daily reset is a consequence of how numbers are scoped, not a scheduled task.

It does not let two kiosks issue the same number [src: app/controllers/app.php:80].

It does not count a recall as a new call [src: app/src/Queue.php:32].

It does not report a negative wait [src: app/src/Queue.php:163].

Looking after your install (from 4.0)

Updates from inside Tokora

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; Tokora 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. Tokora is unavailable for about a minute while this runs. The update replaces Tokora'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 Tokora 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 Tokora 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: a clinic's front desk with a day of queue tokens to try things on. You can remove it in one click. [src: app/install/index.php:142]. 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 services, counters and tokens? 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:83]. 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 Tokora 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 Tokora 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/tokora/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)

Tokora 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 Tokora 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 Tokora'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 Tokora 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:29].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Tokora'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 Tokora 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:12]. If you had Tokora 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 Tokora'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 Tokora · Quickstart · API · Test run