Parse documents in the browser in five minutes
qovox-parser is a client-side SDK. You give it a File, it gives you structured JSON and Markdown. Documents are processed on the user’s device and are never uploaded.
5-minute quickstart
- Create a license key
Sign in to the dashboard, open License keys, click New key and add the domains that will run your app (for example
app.company.com, orlocalhostwhile developing). Copy the key: it is shown once. - Install the SDK
Run
npm install qovox-parser, or add the script tag from Installation. - Initialize with your key
Call
QovoxParser.init({ licenseKey })once, when your page or app starts. It validates your key and domain and resolves to a parser instance. - Parse a file
Pass any
File(from an<input type="file">or drag and drop) toparser.parse(file)and usedoc.markdown,doc.blocksordoc.text. - Ship it
Deploy to a domain on your allow-list. Requests from any other domain fail with
DOMAIN_NOT_ALLOWED.
import { QovoxParser } from 'qovox-parser'; const parser = await QovoxParser.init({ licenseKey: 'QVX-XXXX-XXXX-XXXX-XXXX', // apiBase: 'http://localhost:3000', // only while developing against a local license server }); document.querySelector('#file').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const doc = await parser.parse(file); console.log(doc.markdown); // "# Quarterly Report\n\n..." console.log(doc.stats.pages, 'pages in', doc.stats.ms, 'ms'); });
<input type="file" id="file" accept=".pdf,.docx"> <script src="/docparser.min.js"></script> <script> QovoxParser.init({ licenseKey: 'QVX-XXXX-XXXX-XXXX-XXXX' }).then((parser) => { document.getElementById('file').onchange = async (e) => { const doc = await parser.parse(e.target.files[0]); console.log(doc.markdown); }; }).catch((err) => console.error(err.code, err.message)); </script>
The license key is not a secret from your users’ browser (it has to be in your page), which is why it is locked to your domains. Copying it to another site does not work.
Installation
npm / pnpm / yarn
npm install qovox-parser
Script tag (self-hosted)
Download docparser.min.js from the dashboard, put it on your own domain and load it with a normal script tag. The SDK exposes a global QovoxParser.
<script src="/docparser.min.js" defer></script>
Supported browsers: current Chrome, Edge, Firefox and Safari (16.4+). The SDK needs File.arrayBuffer(), DecompressionStream and DOMParser.
Framework examples
import { QovoxParser } from 'qovox-parser'; const out = document.querySelector('#out'); let parser; async function start() { try { parser = await QovoxParser.init({ licenseKey: 'QVX-XXXX-XXXX-XXXX-XXXX' }); } catch (err) { out.textContent = `License problem: ${err.code}`; // LICENSE_INVALID, DOMAIN_NOT_ALLOWED, ... } } start(); document.querySelector('#file').addEventListener('change', async (e) => { const doc = await parser.parse(e.target.files[0]); out.textContent = doc.markdown; });
import { useEffect, useRef, useState } from 'react'; import { QovoxParser } from 'qovox-parser'; export default function DocUploader() { const parserRef = useRef(null); const [markdown, setMarkdown] = useState(''); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; QovoxParser.init({ licenseKey: import.meta.env.VITE_QOVOX_KEY }) .then((p) => { if (cancelled) p.dispose(); else parserRef.current = p; }) .catch((e) => setError(e.code || e.message)); return () => { cancelled = true; parserRef.current?.dispose(); }; }, []); async function onChange(e) { const file = e.target.files?.[0]; if (!file || !parserRef.current) return; try { setMarkdown((await parserRef.current.parse(file)).markdown); } catch (err) { setError(err.code || err.message); } } return ( <div> <input type="file" accept=".pdf,.docx" onChange={onChange} /> {error && <p role="alert">{error}</p>} <pre>{markdown}</pre> </div> ); }
<script setup> import { ref, onMounted, onBeforeUnmount } from 'vue'; import { QovoxParser } from 'qovox-parser'; const markdown = ref(''); const error = ref(null); let parser = null; onMounted(async () => { try { parser = await QovoxParser.init({ licenseKey: import.meta.env.VITE_QOVOX_KEY }); } catch (e) { error.value = e.code || e.message; } }); onBeforeUnmount(() => parser?.dispose()); async function onChange(e) { const file = e.target.files?.[0]; if (!file || !parser) return; try { markdown.value = (await parser.parse(file)).markdown; } catch (e2) { error.value = e2.code || e2.message; } } </script> <template> <input type="file" accept=".pdf,.docx" @change="onChange" /> <p v-if="error" role="alert">{{ error }}</p> <pre>{{ markdown }}</pre> </template>
API reference
QovoxParser.init(options)
Validates your license and returns a ready-to-use parser. Call it once per page load and reuse the instance. Returns Promise<Parser>. Rejects with a license error if the key, domain or subscription is not valid.
| Option | Type | Default | Description |
|---|---|---|---|
licenseKey required | string | - | Key from the dashboard, format QVX-XXXX-XXXX-XXXX-XXXX. |
apiBase | string | production license API | Base URL of the license server. Set to http://localhost:3000 when developing against a local server. |
threads | number | 'auto' | 'auto' | Worker threads for parsing. Multi-threading needs a cross-origin-isolated page; otherwise a single worker is used. |
reportUsage | boolean | false | When true, sends anonymous counters (number of documents and pages) to your usage analytics. Never any content or file names. |
onLicense | (info) => void | - | Called after every successful license check with { plan, expiresAt, features }. |
onError | (err) => void | - | Called when a background license refresh fails (the parser keeps working during the offline grace period). |
const parser = await QovoxParser.init({ licenseKey: 'QVX-XXXX-XXXX-XXXX-XXXX', reportUsage: true, onLicense: ({ plan, expiresAt }) => console.log(`licensed (${plan}) until ${expiresAt}`), });
parser.parse(file, options?)
Parses a PDF or DOCX file entirely in the browser. Returns Promise<ParsedDocument>. The file type is detected from its contents, not its name.
| Parameter | Type | Description |
|---|---|---|
file required | File | Blob | The document. Maximum size depends on device memory; 40 MB in the fallback engine. |
options.signal | AbortSignal | Cancel a long parse. The promise rejects with an AbortError. |
options.onProgress | ({ page, total }) => void | Called as pages complete. |
options.pages | number[] | Only parse these 1-based page numbers (PDF only). |
const controller = new AbortController(); const doc = await parser.parse(file, { signal: controller.signal, onProgress: ({ page, total }) => (bar.value = page / total), });
The ParsedDocument result
| Field | Type | Description |
|---|---|---|
file | { name, size, type } | Basic file info. type is 'pdf' or 'docx'. |
meta | object | Title, author, page count and other document properties when available. May contain a warning string (for example for scanned PDFs). |
blocks | Block[] | Structured content in reading order (see below). |
markdown | string | The whole document as Markdown: headings, lists, tables, bold/italic and links (DOCX). |
text | string | Plain text with blocks separated by blank lines. |
stats | object | { pages, blocks, headings, tables, words, characters, ms } |
Each entry of blocks has a type:
| type | Fields |
|---|---|
heading | level (1–6), text, page? |
paragraph | text, page? |
list_item | text, ordered, index?, depth?, page? |
table | rows: string[][] (DOCX tables) |
{
"file": { "name": "report.pdf", "size": 2232, "type": "pdf" },
"meta": { "format": "pdf", "pages": 2, "title": "Quarterly Report" },
"blocks": [
{ "type": "heading", "level": 1, "text": "Quarterly Report", "page": 1 },
{ "type": "paragraph", "text": "Revenue grew 18% quarter over quarter...", "page": 1 },
{ "type": "list_item", "ordered": false, "text": "Annual recurring revenue reached $4.2M", "page": 1 }
],
"markdown": "# Quarterly Report\n\nRevenue grew 18%...",
"stats": { "pages": 2, "words": 105, "ms": 28 }
}Other methods & properties
| Member | Description |
|---|---|
parser.license | Read-only { plan, expiresAt, features } from the last successful license check. |
parser.refreshLicense() | Forces an immediate license check. Resolves with the new license or rejects with a license error. |
parser.dispose() | Stops background refresh timers and terminates workers. Call it when your component unmounts. |
QovoxParser.version | SDK version string. |
TypeScript types
export interface InitOptions { licenseKey: string; apiBase?: string; threads?: number | 'auto'; reportUsage?: boolean; onLicense?: (info: LicenseInfo) => void; onError?: (err: QovoxError) => void; } export interface LicenseInfo { plan: 'dev' | 'enterprise'; expiresAt: string; features: Record<string, unknown> } export type Block = | { type: 'heading'; level: number; text: string; page?: number } | { type: 'paragraph'; text: string; page?: number } | { type: 'list_item'; text: string; ordered: boolean; index?: number; depth?: number; page?: number } | { type: 'table'; rows: string[][] }; export interface ParsedDocument { file: { name: string; size: number; type: 'pdf' | 'docx' }; meta: Record<string, unknown>; blocks: Block[]; markdown: string; text: string; stats: { pages: number | null; blocks: number; headings: number; tables: number; words: number; characters: number; ms: number }; } export class QovoxError extends Error { code: string; status?: number } export interface Parser { license: LicenseInfo; parse(file: File | Blob, options?: { signal?: AbortSignal; onProgress?: (p: { page: number; total: number }) => void; pages?: number[] }): Promise<ParsedDocument>; refreshLicense(): Promise<LicenseInfo>; dispose(): void; } export const QovoxParser: { init(options: InitOptions): Promise<Parser>; version: string };
How licensing works
init()sendsPOST /v1/heartbeatwith your license key and SDK version. The browser adds the page’sOriginheader; page scripts cannot change it.- The license server checks that the key exists, is not revoked, its subscription is valid, and that the origin matches one of the key’s allowed domains.
- On success it returns a short-lived (1 hour) signed session token. The SDK refreshes it in the background about every 50 minutes.
- If a refresh fails because the network is down, parsing keeps working for an offline grace period (24 hours on Dev, 7 days on Enterprise). Revocation and expiry take effect at the next successful check.
What is sent: license key, page origin (domain), SDK version, and, only if you enable reportUsage, counts of documents and pages. Never sent: file contents, file names, extracted text.
Allowed domains
app.company.commatches exactly that host (any scheme, any port).*.company.commatches every subdomain (a.company.com,a.b.company.com) but notcompany.comitself: add both if you need both.localhostallows local development on any port;localhost:5173pins a port.- Wildcards on public suffixes such as
*.comare rejected.
Error codes
Errors are instances of QovoxError with a stable code string. Always branch on err.code, never on the message text.
License errors (from init() / refreshLicense())
| Code | HTTP | Meaning | How to fix |
|---|---|---|---|
LICENSE_INVALID | 401 | The key is missing, malformed or unknown. | Check for typos or stray whitespace. Make sure you copied the full key from the dashboard. Create a new key if it was lost. |
DOMAIN_NOT_ALLOWED | 403 | The page’s domain is not on the key’s allow-list (or the browser sent no Origin, e.g. a file:// page). | Add the domain in Dashboard → License keys → Edit. Add localhost for local development. Serve the page over http(s), not file://. |
LICENSE_EXPIRED | 403 | The subscription or the key’s own expiry date has passed. | Renew or change your plan in Dashboard → Billing. The key works again at the next license check. |
LICENSE_REVOKED | 403 | The key was revoked in the dashboard. | Create a new key and deploy it. Revoked keys cannot be restored. |
ACCOUNT_FROZEN | 403 | The account that owns the key was suspended by an administrator. | Contact support at qovox222@gmail.com. |
SERVER_PAUSED / MAINTENANCE | 503 | The license service is temporarily paused or under maintenance. | Nothing to fix: the SDK keeps working during the offline grace period and retries automatically. Check the status badge in the site header. |
RATE_LIMITED | 429 | Too many license checks from one IP. | Call init() once per page load and reuse the parser. Retry after the Retry-After delay. |
NETWORK_ERROR | - | The license server could not be reached and no valid grace-period token exists. | Check connectivity, your apiBase and your Content-Security-Policy connect-src. |
Parsing errors (from parse())
| Code | Meaning |
|---|---|
UNSUPPORTED_TYPE | The file is neither a PDF nor a DOCX. |
NOT_PDF / NOT_DOCX | The file looks like one of the formats but has no valid structure (for example a ZIP that is not a Word document). |
ENCRYPTED_PDF | The PDF is password-protected. |
CORRUPT | The file is damaged or truncated. |
EMPTY | The file has zero bytes. |
TOO_LARGE | The file, or its decompressed contents, exceeds the safety limit. |
UNSUPPORTED_BROWSER | A required browser API (such as DecompressionStream) is missing. |
try { const parser = await QovoxParser.init({ licenseKey }); const doc = await parser.parse(file); } catch (err) { switch (err.code) { case 'DOMAIN_NOT_ALLOWED': showBanner('This site is not licensed for qovox-parser.'); break; case 'LICENSE_EXPIRED': showBanner('Document parsing is paused: license expired.'); break; case 'ENCRYPTED_PDF': showToast('Please remove the password and try again.'); break; default: console.error(err); } }
License REST API
The SDK talks to this API for you. You only need it if you build your own client or verify tokens on your server. Errors always look like { "ok": false, "error": { "code", "message" } }.
POST /v1/heartbeat
POST /v1/heartbeat Content-Type: application/json Origin: https://app.company.com (added by the browser) { "licenseKey": "QVX-XXXX-XXXX-XXXX-XXXX", "sdkVersion": "1.0.0" }
{
"ok": true,
"token": "eyJhbGciOiJFZERTQSIs...", // EdDSA (Ed25519) signed JWT, 1 hour
"expiresAt": "2026-10-06T12:00:00.000Z",
"refreshAfterSeconds": 3000,
"plan": "dev",
"features": { "watermark": false, "offlineGraceHours": 24 }
}Token claims: iss (qovox-license), sub (license id), aud (the allowed host that was checked), plan, features, iat, exp, jti. Fetch the verification key from GET /v1/public-key (a JWK).
const crypto = require('crypto'); const { jwk } = await (await fetch(`${API}/v1/public-key`)).json(); const key = crypto.createPublicKey({ key: jwk, format: 'jwk' }); function verify(token) { const [h, p, sig] = token.split('.'); const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(sig, 'base64url')); const claims = JSON.parse(Buffer.from(p, 'base64url')); return ok && claims.exp > Date.now() / 1000 ? claims : null; }
Other endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /v1/usage | session token | Report { documents, pages } counters (used when reportUsage is on). |
POST /v1/auth/register · /login | - | Create an account / sign in; returns a dashboard token. |
GET /v1/licenses · POST /v1/licenses | dashboard token | List keys / create a key (name, allowedOrigins[]); the full key is returned once. |
PATCH /v1/licenses/:id · POST /v1/licenses/:id/revoke · DELETE /v1/licenses/:id | dashboard token | Edit name or domains / revoke / delete. |
GET /v1/usage?days=30 | dashboard token | Daily usage series and per-key totals. |
GET /v1/billing · POST /v1/billing/subscribe · /cancel | dashboard token | Billing summary and (mock) plan changes. |
GET /health | - | Liveness probe. |
Headers & CSP
Single-threaded parsing works on any page. To enable multi-threaded parsing the page must be cross-origin isolated, which needs these response headers on your HTML:
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp
require-corp blocks cross-origin images, scripts and iframes that do not opt in with CORS or Cross-Origin-Resource-Policy. Test your page after enabling it.
If you use a Content-Security-Policy, allow the SDK’s worker and the license endpoint:
script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; connect-src 'self' https://your-license-api.example.com;
Your license server must be reachable from your users’ browsers. The /v1/heartbeat endpoint answers CORS preflights for any origin, because the allowed-domain list is the real access control.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
DOMAIN_NOT_ALLOWED on localhost | Add localhost (or 127.0.0.1) to the key’s domains. Open the page through a dev server rather than file://. |
Console shows a CORS error on /v1/heartbeat | The license server is down or the URL is wrong. Check apiBase; run npm start in site/server for local development. |
| Blocked by Content-Security-Policy | Add the license API origin to connect-src and blob: to worker-src. |
Empty blocks and a meta.warning about scans | The PDF has no text layer (it is an image). Run OCR first. |
| Parsing works, then stops after a day offline | The offline grace period ended. Reconnect so the SDK can refresh its token. |
RATE_LIMITED in tests | You call init() repeatedly. Create the parser once and reuse it. |
