Specta Manual
Specta — User Manual
Supplier catalogs and spec sheets, normalized into product rows you can import. Version 4.1.3 [src: app/controllers/api.php:108].
About this manual
Every statement here was written by reading Specta'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 Specta is
You declare the columns you want. You upload a supplier's PDF or spreadsheet. A model extracts the products, every value carries a confidence score, and what comes out is a CSV or JSON file a store's importer will accept [src: app/controllers/v3.php:290].
The extraction is the only part a model touches. The mapping, the scoring and the export are pure and offline — no database, no network — which is what allows all of it to be tested [src: app/src/Catalog.php:6].
Schemas: declaring what you want
A schema is a named template of attribute columns [src: app/src/Catalog.php:3]. Each column carries a key, a label, a role, a type, whether it is required, and an optional unit [src: app/src/Catalog.php:13].
Roles are name, sku, price and attribute [src: app/src/Catalog.php:23]. They do two jobs: they promote a column to a canonical product field, and they decide how it maps into each export preset [src: app/src/Catalog.php:13].
Type, required and unit steer two further things — the prompt the model is given, and the offline confidence scoring described below [src: app/src/Catalog.php:14].
Column labels become safe keys automatically. "Width (mm)" becomes width_mm; a key that would start with a digit is prefixed so that it cannot [src: app/src/Catalog.php:44].
A schema is capped at sixty columns, so that a pasted schema cannot blow up the interface or an import file [src: app/src/Catalog.php:28].
Confidence scoring — the part worth understanding
Every extracted value gets a score between 0 and 1. The model is never asked how confident it is. The score is computed offline from the value as printed [src: app/src/Extraction.php:115], which is the only honest way to do it — a model's own confidence report is not evidence.
The scoring is published in the source rather than hidden [src: app/src/Extraction.php:100]:
| Case | Score |
|---|---|
| Empty, and the column is required | 0.0 |
| Empty, and the column is optional | 1.0 |
A placeholder — n/a, -, TBD, unknown | 0.3 |
| Text, non-empty | 1.0 |
| Text over 500 characters | 0.7 — truncation risk [src: app/src/Extraction.php:101] |
| Numeric, nothing parseable | 0.2 |
| Numeric, clean as printed | 1.0 |
| Numeric, coerced out of prose ("approx 42 kg total") | 0.65 [src: app/src/Extraction.php:106] |
Then three caps apply: a negative value or a magnitude above ten million is capped at 0.4 as implausible [src: app/src/Extraction.php:108], and a column with a unit hint whose unit is absent from the value is capped at 0.9 [src: app/src/Extraction.php:109].
The list of recognized placeholders is explicit [src: app/src/Extraction.php:89].
A field you have confirmed yourself is stored as 1.0 and never recomputed [src: app/src/Extraction.php:113]. Your judgment is not overwritten by the scorer on the next pass.
What the scores are used for
A catalog's lowest-scoring field is available as a single number [src: app/src/Catalog.php:354], and a record needs review when any field falls below your threshold [src: app/src/Catalog.php:360] — not when the average does, which would let one bad field hide behind nine good ones.
Scoring works on already-stored records too, for a catalog that predates the feature or has been edited by hand [src: app/src/Catalog.php:344].
Extraction, and what is skipped
Rows that carry nothing — no name, no SKU, no price and no attribute value — are dropped rather than imported as blanks [src: app/src/Catalog.php:299]. The scoring path applies the identical rule, so the scored count and the record count always match [src: app/src/Catalog.php:315].
Batches
A batch is a stack of uploaded sheets, capped at twenty files [src: app/src/Batch.php:24].
They are processed one file per request [src: app/src/Batch.php:5]. There is no cron job, no long-running request and no queue daemon, which is a deliberate concession to shared hosting — the interface drives it with a "Process next" button or an auto-advancing page.
Each file runs queued → extracting → extracted, or → failed [src: app/src/Batch.php:25]. A failed file does not stop the batch: its error is stored and the rest is untouched [src: app/src/Batch.php:10], and a failed file can be returned to the queue and retried.
The whole state machine is HTTP-free — no session, no redirect, no output [src: app/src/Batch.php:15] — and the model call is injected rather than hard-wired, which is how the tests and demo mode run the entire flow with no API key at all [src: app/src/Batch.php:17].
Suppliers: the next list from the same supplier (3.2.0)
Most suppliers send a new price list every month. Suppliers (in the menu) keeps a profile for each: the template their sheets go through and, if you like, the https:// address their current list lives at [src: app/controllers/v320.php:340]. New catalog opens the upload form with the supplier and template already chosen. Saving under an existing supplier's name, in any case, updates that profile; the page also lists supplier names on your catalogs that have no profile yet.
With an address, Fetch now downloads the list and files it as a new catalog only when the file has changed since the last fetch (compared by its SHA-256), so an unchanged list files nothing [src: app/controllers/v320.php:401]. A schedule (daily, weekly or monthly) runs from your own cron with php bin/fetch-suppliers.php, which fetches only the profiles that are due [src: app/src/Suppliers.php:31]. Reading a fetched list stays a click, Extract products, because an extraction spends your model credits. The fetch reads https:// only, refuses an address that resolves to a private or reserved network, connects to the address it checked, follows no redirect and stops at 20 MB; a web page instead of a PDF or image is refused and named on the Suppliers page [src: app/src/Suppliers.php:48].
A catalog from a supplier who sent one before shows Since the last list: prices that changed, products that are new and products that are gone, matched by SKU (in any case) and otherwise by name [src: app/src/Suppliers.php:99].
Against your store (3.3.0)
Before you import a list, Against your store on the catalog page compares it with your store's own product export: Shopify's products CSV, WooCommerce's, or any CSV with a SKU or a name column (comma, semicolon or tab separated) [src: app/views/catalog_show.php:197]. It shows the products in this list that your store does not have yet, and the products in your store that this list no longer has, matched by SKU (in any case) and otherwise by name [src: app/src/StoreCheck.php:136]. Shopify puts a product's title and vendor on its first row only, so they are carried down to its variant rows, and a row that only adds an image is skipped [src: app/src/StoreCheck.php:60].
If your store also sells other suppliers' products, name this supplier's vendor as your store writes it (Shopify's Vendor column, or a Brand column): only your store's products from that vendor are compared. The field starts with the catalog's supplier name. A file without such a column is compared whole, and the page says so. The file (up to 20 MB and 100,000 rows) is read row by row in the request and dropped: nothing is stored, and anyone who can view catalogs can run it. The counts are always whole; each list shows its first 500 [src: app/controllers/v320.php:470]. The public demo has a Try the sample store button, a small Shopify-style export for the Bright Lighting list.
Catalog status is derived
A catalog's status is rolled up from its products rather than stored: no products yet means pending, or extracted if a run has happened; all products reviewed means reviewed; anything else means extracted [src: app/src/Catalog.php:395].
Exporting
Three presets [src: app/src/Catalog.php:26]:
| Preset | Shape |
|---|---|
plain | One column per schema column, your labels as headers |
woocommerce | WooCommerce's product CSV importer format, with attribute name/value/visible triples |
shopify | Shopify's product CSV, with attributes folded into the body |
All three are described in the source [src: app/src/Catalog.php:443] and written with CRLF line endings [src: app/src/Catalog.php:449], which is what those importers expect.
Reviewed products only (3.2.0). When some products are still unreviewed, the Export menu also offers the WooCommerce, Shopify and JSON files with the reviewed products only, and says how many that is; the API takes reviewed=1 for the same [src: app/controllers/v320.php:457]. It is the check before unreviewed values reach a live store.
JSON export gives the canonical records with a decimal price and the full attribute map [src: app/src/Catalog.php:994].
Variants and images (3.2.0)
A supplier sheet prints one row per color and size; a store wants one product with options. When a template has attribute columns named size or color, the catalog page proposes groups [src: app/src/Variants.php:54]: rows whose names read the same once their size and color are taken out, and whose other attributes match (price and SKU may differ). No row joins a group until a reviewer presses Group these (and may retitle it) [src: app/controllers/v320.php:57]; Not variants stops the proposal, and Ungroup undoes a group. Color temperature is a specification, not an option.
In the built-in Shopify preset a confirmed group is one handle with a row per variant, Option1/Option2 naming the options on the first row [src: app/src/Catalog.php:616]; in the WooCommerce preset it is one variable parent (its SKU the members' common prefix) and a variation row per member naming its Parent [src: app/src/Catalog.php:536]. A product's image URL is typed on the product, or filled from a pattern such as https://cdn.example.com/images/{sku}.jpg for every product that has none [src: app/controllers/v320.php:117]; it goes into Shopify's Image Src and WooCommerce's Images, and into the JSON. The new columns are added only when a file has variants or images, after the existing ones, so a file without them is exactly what it was. A saved mapping keeps its own columns and can take the image URL as a source.
Image descriptions (alt text, 3.2.0)
A product with an image URL also gets words for its picture, for screen readers and search: Shopify's Image Alt Text, the JSON image_alt, and a mapping source of the same name [src: app/src/Catalog.php:513]. Type a description on the product, or leave it blank: Specta then uses the approved written title (else the name), and for a variant the group's title with that variant's own values, such as "Crew Tee — Black, M". It is capped at Shopify's 512 characters.
Titles and descriptions (3.2.0)
A PIM's writing tools will happily invent a "breathable" or a "lasts for years" that no supplier ever printed. Specta writes from confirmed facts only: a product has to be reviewed first, and the model is given its name and its non-empty attributes, never the price and never a placeholder such as "n/a" [src: app/src/Enrich.php:25]. The instruction says to add no claim, feature, material, size, figure, certification, use or benefit that is not in the facts, and to write less when the facts are thin [src: app/src/Enrich.php:44]. A product with nothing but a name is skipped.
On the catalog page, Write titles and descriptions writes for up to ten reviewed products per click, on your own model key [src: app/controllers/v320.php:153]. It is gated like an extraction (batch.run), because it spends your model credits. Write in sets the language (British English unless you name another; it is remembered).
Every text arrives as generated — not yet approved. On the product, it sits beside the facts it was written from, and every number in it that is not in those facts is named as a warning ("210 g" in the facts is fine; a "250 g" is flagged) [src: app/src/Enrich.php:75]. Approve (edit it first if you like) or Discard [src: app/controllers/v320.php:188]. Only approved text reaches the Shopify and WooCommerce exports, as the product's title and the lead paragraph of its description; a variant group keeps the title you confirmed and takes its first member's approved description. Without an approved text, an export is exactly what it was.
Sheets to hand out (PDF)
Every product has a one-page spec sheet (/products/{id}/spec.pdf), and since 3.2.0 the whole catalog has a catalog sheet: Export → Catalog sheet (PDF, reviewed products) [src: app/controllers/v320.php:217]. It lists reviewed products only, because an unreviewed value is not something to print for a customer; the sheet says how many it left out [src: app/src/Sheet.php:25]. Each product is a section with its SKU, price, description and specifications; a confirmed variant group is one section: its title, the specifications its variants share, then a table of the variants with their own option values and prices. The description is the approved written one where there is one, else the supplier's own. A value the template marks as needed but the product lacks is printed as "— not specified", as on the spec sheet, so the gap stays visible. Both sheets use an approved written title and description; a generated one nobody approved never appears. Anyone who can export, viewers included, can download them.
Marketplace feeds (3.2.0)
A store import builds the shop; a feed lists the same products for Google Merchant Center or a Meta catalog. The Marketplace feeds card on the catalog page takes four settings, remembered per catalog: the product page address with {handle}, {sku} or {id} in it (for example https://shop.example.com/products/{handle}, where {handle} is the web address the Shopify export writes), the brand (the supplier until you change it), the condition, and one availability for every row, because Specta does not track stock [src: app/src/Feed.php:33]. Google Merchant feed downloads a tab-separated file; Meta catalog feed a CSV [src: app/controllers/v320.php:291]. The column names and values follow each vendor's published specification, read on 26 September 2026 (Google writes in_stock, Meta in stock; the price is 12.00 GBP); check them against the vendor's current page before your first upload.
Only reviewed products go in, and only those with a price, an image URL and a page address; the card names every reviewed product left out and why, instead of the marketplace rejecting it later [src: app/src/Feed.php:50]. Titles and descriptions are the approved written ones where you have them, else the product's name and the supplier's description, else its specifications as a sentence. A confirmed variant group's rows share one item_group_id (the parent SKU, worked out as in the WooCommerce export) and the group's page, with color and size from its option columns. A template column named EAN, GTIN, UPC or barcode adds gtin (digits only); one named MPN adds mpn. Uploading the file is yours to do.
Checking a mapping before you import
This is the feature to know about, because it turns a failed import into a five-second check.
Paste the header row from your store's own import template, and Specta compares it against the columns your export would actually produce [src: app/src/Catalog.php:933]. You get back four lists and a verdict:
- missing — the store asks for it and your export would not produce it, so the import breaks or blanks the field [src: app/src/Catalog.php:929]
- extra — your export produces it and the store's template has no slot, which some importers reject [src: app/src/Catalog.php:930]
- matched — present on both sides
- duplicates — the same column name twice in the store's header
The verdict is exact, covered or gaps.
Two details show the feature was built against real pasting rather than a clean test case. A non-breaking space — which is what a browser copy usually gives you — is normalized away [src: app/src/Catalog.php:920]. And pasting a sentence instead of a header row gets you a specific explanation rather than a nonsense result [src: app/src/Catalog.php:949].
Column order is reported, never failed on [src: app/src/Catalog.php:975], because order only matters to importers that read positionally.
Users, roles and what each may do
Three roles. This table was produced by executing the product's own permission function against every permission the routes ask for, rather than by reading its map.
| Permission | viewer | member | admin |
|---|---|---|---|
catalog.view | yes | yes | yes |
catalog.export | yes | yes | yes |
audit.view | yes | yes | yes |
catalog.edit | — | yes | yes |
product.edit | — | yes | yes |
schema.edit | — | yes | yes |
batch.run | — | yes | yes |
views.save | — | yes | yes |
settings.edit | — | — | yes |
users.manage | — | — | yes |
The source names both seats. A member is the catalog operator who uploads, runs extractions, corrects products and edits schemas [src: app/controllers/v3.php:50]. A viewer reads and exports — the seat for the person downstream who consumes the normalized data, a marketplace manager or a client, and who must not be able to change what a supplier's sheet said [src: app/controllers/v3.php:51].
Why batch.run is its own permission
Because running an extraction spends your AI credits. It is separated deliberately so that you can hand somebody editing rights without handing them the spend [src: app/controllers/v3.php:54].
The API and agent access
The REST interface sits behind a key [src: app/controllers/api.php:108].
Six tools are exposed to an agent, and every one of them is read-only [src: app/controllers/v3.php:295]: listing catalogs, catalog detail, listing items, item detail, export rows and batch status.
The reason is given in the instructions the agent receives, and it is the right one: these tools cannot start an extraction, because a run spends the operator's own AI credits, so it stays a deliberate action in the browser [src: app/controllers/v3.php:294].
What Specta does not do
It does not let an agent spend your money [src: app/controllers/v3.php:294]. Extraction is a browser action, by design.
It does not ask the model how confident it is. Confidence is computed offline from the printed value against your column's declared type, unit and required flag [src: app/src/Extraction.php:115]. That means a score is a statement about the text, not about the model's mood.
It does not push to your store. It writes files WooCommerce and Shopify importers accept [src: app/src/Catalog.php:443], and the mapping checker tells you in advance whether the file fits — but the upload is yours.
It does not judge whether a supplier's figure is correct. A clean, well-formed, entirely wrong price scores 1.0. The score measures how confidently the value was read, not whether the sheet was right.
Looking after your install (from 4.0)
Updates from inside Specta
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; Specta 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. Specta is unavailable for about a minute while this runs. The update replaces Specta'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 Specta 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 Specta 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: sample templates and two supplier catalogs ready for review to try things on. You can remove it in one click. [src: app/install/index.php:156]. 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 templates, catalogs and products? 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 (open /restore; it has no menu link), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:86]. 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 Specta 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 Specta 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/specta/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)
Specta 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 Specta on the store shows the same file [src: app/API.md].
New in 4.1.2: sign-in limits
New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. Accepting an invite starts a fresh session. Single sign-on starts a fresh session. On Ownware Cloud, opening the app from the console starts a fresh session.
Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].
Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.
If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Specta's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.
Accepting an invite now starts a fresh session (a new session id), as every other sign-in already did [src: app/controllers/v3.php:693].
Single sign-on now starts a fresh session too [src: app/controllers/v3.php:738].
On Ownware Cloud, opening Specta from the console starts a fresh session [src: app/index.php:67].
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:23].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Specta'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 Specta 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 Specta 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 Specta'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.