Web SDKv1.0.0

Licensing

A valid license — development or production — is required. Kernstra.init() verifies it before anything else happens: without one it shows a "License required" popup, rejects with LICENSE_REQUIRED, and never downloads the engine. Get a license on the pricing page (opens in a new tab).

Pass the token to init, typically from an environment variable named KERNSTRA_LICENSE (with Vite, add envPrefix: ['VITE_', 'KERNSTRA_'] to vite.config.ts so browser code can read it as import.meta.env.KERNSTRA_LICENSE; with Next.js, expose it through env in next.config.ts), or set it where the SDK looks when none is passed: a globalThis.KERNSTRA_LICENSE string, a <meta name="kernstra-license" content="…"> tag, or, in Node, process.env.KERNSTRA_LICENSE. (The SDK ships prebuilt, so a bundler cannot substitute an environment variable into it; read the variable in your code.) An empty or missing value means "no license given"; only Kernstra.setLicense('') signs out and forgets the cached token.

Kernstra.setLicense(token) replaces the license at runtime, Kernstra.getLicense() reads its current state, and Kernstra.onLicenseChange(cb) — or the licenseChanged event on Kernstra.events — reports every change. All four hand you the same shape, the exported LicenseState, a union narrowed on valid.

Example

TypeScript
await Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE });

type LicenseState = LicenseStateValid | LicenseStateInvalid;

interface LicenseStateValid {
  valid: true;
  watermark: boolean; // true while output is marked: a development token
  env: 'development' | 'production';
  plan: string;
  features: LicenseFeature[]; // 'edit-text' | 'add-text' | 'edit-pages' | 'annotations' | 'forms' | 'export' | 'print' | 'extract'
  expiresAt?: Date; // only on a license with an expiry date
  offline: boolean; // air-gapped: no status check, no network calls
}

interface LicenseStateInvalid {
  valid: false;
  watermark: true; // unlicensed output is marked
  reason: LicenseInvalidReason; // why the license is not valid
  pauseReason?: LicensePauseReason; // why, when reason is 'paused'
}

LicenseInvalidReason

reasonMeaning
no-licenseNo token was passed and none was found where the SDK looks
insecure-contextPlain HTTP: the browser provides no Web Crypto API there
malformedThe token cannot be parsed
unsupported-algorithmThe token is signed with an algorithm this SDK does not verify
invalid-signatureThe signature does not verify
not-yet-validThe token is not yet in force
expiredThe token's own expiry has passed (an offline license always has one)
origin-mismatchThe page is on a host the license does not name
wrong-holderThe token is bound to another browser's key, found on this one (when the licensing API is the one to find a copied token, the reason is revoked)
revokedThe licensing API revoked the key; refused at every load after
pausedPaused by the licensing API; pauseReason says why
staleThe licensing API has not confirmed the license for more than 48 hours, or never on this browser and the check could not get through; the SDK loads, watermarked and without extract, and asks again whenever a document is opened, so a page recovers without a reload once the API answers

pauseReason

pauseReasonPopup text
paymentThis license is paused because of a missing payment.
suspendedThis license is paused because Kernstra suspended it.
term_endedThis license's term has ended. Contact the license owner to renew it.
trial_endedThis trial license has ended. Contact the license owner.
otherThis license is paused. Contact the license owner.

License state

StateDocuments open?WatermarkFeatures
Valid, productionYesNoneAs the token lists
Valid, developmentYesOn screen, in print and in every saved or exported fileAs the token lists
stale (API not reached for 48 h, or never on this browser)YesYes, as developmentThe token's, kept, but extract (data read out carries no mark)
paused, revoked, expired, copied or forged keyNo: init() and loaders refuse with LICENSE_REQUIRED; documents already open keep their rights—None for new documents
No licenseNo: init() refuses——

What each feature unlocks

FeatureIn the toolbar and viewerThrough the API
edit-textEdit text tool: retyping and restyling the document's own textviewer.tools.editText.* (opening or retyping a block from code also needs extract)
add-textAdd text tool: new text written into the page as page content (the Text box tool, a comment on top of the page, is annotations)— (a tool only)
annotationsSign, Draw, Line, Arrow, Rectangle, Ellipse, Polygon, Polyline, Check, Cross, Dot, Erase, Highlight, Text highlight, Image, Stamp, Link, Note, Text box (Image, Sign and Black out write into the page itself; Erase can remove the page's own content); replying, editing, deleting and status in the Comments panel; the selection bar's highlight, comment, underline, squiggly, strike-through, note and black-out actionsdoc.annotations create, update, delete, flatten, reply, setStatus (a form field's widget needs forms too); exportXFDF also needs extract, importXFDF also forms
edit-pagesManage Pages (insert, delete, move, rotate, duplicate, import) and the thumbnails' page actions (rotate, move, duplicate, delete); editing bookmarks in the Outline panel; attaching and removing files in the Attachments paneldoc.pages.insert / delete / move / rotate / setRotation / duplicate / import, doc.outline.add / delete / set / setTitle, doc.attachments.add / delete
formsTyping into form fields and signing signature fields (without it every field shows read-only); typed values go into exportsdoc.forms.setValues, doc.annotations.importXFDF (with annotations)
printPrint buttonviewer.print()
exportDownload button; Extract pages in Manage Pages (which needs edit-pages to open); the Security tool (passwords and permissions: how the saved file is protected — and, without export, there is no button to enter an owner password; a host calls doc.security.unlockOwnerPermissions, which needs no feature)doc.save(), every PDFExporter method (download, getBlob, getArrayBuffer, getBase64, uploadTo), doc.pages.extract, doc.security.protect / removeProtection
extractDownloading an attachment from the Attachments panel; copying text and the text layer on a development or stale license (production allows them anyway)Reading the document's content out: doc.text.extract / search, viewer.search, doc.forms.fields / getValues, doc.attachments.data, doc.annotations.exportXFDF, opening or retyping text blocks from code. Members that list content (annotations.list / comments, outline.get, attachments.list, signatures.list, metadata, editText.listBlocks) still answer without it, with the words blanked; so do the textChanged event and the form-field callbacks

What a key carries

Production keys, every planDevelopment keys
What it is forYour live app: clean output, bound to your originsBuilding the integration (watermarked)
Viewing (no feature needed)✓✓
print✓✓
extract✓✓ (paying organizations only)
annotations✓✓
forms✓✓
export✓✓
edit-pages✓✓
edit-text✓✓
add-text✓✓

Notes

The popup in your words. In a product of your own, the person who meets the popup is your user, not your developer — a paused production license after a missed payment, a domain the key does not name after a move — and the SDK's sentence naming the reason is not what they should read. Kernstra.init({ licensePopup }) gives the popup your title, text and Close label; each part replaces the SDK's own, and what you leave out stays (a part that is not a string rejects init with INVALID_ARGUMENT). The link is not yours to change: it goes to kernstra.com, where a license is bought. The enforcement is unchanged — the popup shows over the same loads, the watermark and the refusals stay — and the reason stays in Kernstra.getLicense() and in the rejection's message, for your logs. In code: await Kernstra.init({ license, licensePopup: { title: 'Editing is unavailable', text: 'The editor is temporarily unavailable. Please try again later.' } });.

Development tokens watermark every rendered, printed and exported page with the Kernstra mark, so development output can never ship as production. Because of that they may name * and run on any host — a laptop, a CI runner, a preview deployment — or be bound to your local and staging origins. Production tokens are bound to your origins and render clean output.

Secure context. The SDK runs only where the browser provides the Web Crypto API — over HTTPS or on localhost — which also holds the key created once for this browser. On a plain-HTTP origin it refuses to start (LICENSE_REQUIRED, reason insecure-context). This holds for offline (air-gapped) licenses too: an intranet deployment needs HTTPS, a self-signed certificate is enough. There is deliberately no fallback for plain HTTP. In Node, where a page has no hostname, only an offline or a development license whose origins include * verifies.

Origins. Patterns name a hostname, optionally with a port: pdf.acme.com, *.acme.com (subdomains, not the apex), localhost:3000. :80 and :443 also match the default port. An IPv6 literal is bracketed as the browser reports it, [::1]. An offline license and a development license may name * and run anywhere, including from a desktop shell with no hostname; an online production one never may. A load from a host the license does not name is refused, and the refusal is reported to the licensing API (that load waits for the answer), so the license owner sees it and Kernstra can approve the host with an updated key; until then the load stays refused.

Only what the licensing API signed counts. The token is verified locally in milliseconds; its status is confirmed with the licensing API once per load (init or setLicense). The browser keeps the API's last answer as signed, and verifies it again on every load. A load that holds a recent good answer never waits: the check runs in the background — on every load, so a revocation reaches the next load that gets through, however the device clock is set. A load without one — the first on a browser, site data cleared, a frame without storage, an answer older than 48 hours, or a paused license — waits for the check (at most 8 seconds). Nothing polls while a document is open. A busy API (429, 503) is left alone for as long as its Retry-After asks (at most an hour), by every tab.

Never mid-work. An online license key never expires on its own: it has no expiry date, and the licensing API decides at each check whether it is still in force. Each document keeps the rights it was opened with until it is closed. A verdict that takes rights away — revoked, paused, fewer features, the outage grace period run out — is reported at once through licenseChanged, but reaches only the next load (init, setLicense, or opening a document): whoever is exporting, printing or filling a form in an open document finishes. Opening a document under a stopped license (paused, revoked, expired, a copied or forged key) is refused with LICENSE_REQUIRED: no document opens, so no text or data leaves either. A page may stay open for days on one init: every document it opens takes the license as it stands then — past the grace period it is unverified, past an offline license's expiry it is expired, a pause or revocation another tab of the browser received counts — and when the last signed answer is more than an hour old, opening a document checks the license again in the background, so a pause reaches a page that is never reloaded. Importing pages into an open document (doc.pages.import, or Import PDF in Manage Pages) is a load for that document, once the imported file has opened; under a stopped license it is refused (LICENSE_REQUIRED), as opening a document is. Signing out (setLicense('')) forgets the key but not a stop: only a license that loads lifts a pause or a revocation. Listen to licenseChanged if your app should react sooner. A verdict that adds rights reaches open documents at once. A revoked key is refused at every load after (without asking again while the browser keeps that revocation); a paused one (a missing payment, a suspension, a license term that ended) stays paused, even across reloads and with the API unreachable, until the licensing API says it is back in force, and then the same key works again. Presenting the same key again with setLicense changes neither. When the licensing API updates a key so that it no longer names the page's host, that host is refused from then on.

Licensing-API outages. An outage does not affect a valid license for 48 hours after the licensing API last signed an answer for it. Past that, and on a browser with no signed answer at all, the SDK still loads but output is watermarked until a check succeeds (getLicense() says why) — the engine and fonts still load. Clearing site data does not restart the 48 hours. The device clock must be within 48 hours of the real time for a check to count. Air-gapped (offline) licenses never contact the API and are exempt.

No endpoint in the SDK. The token itself names the licensing API. There is nothing to configure.

The binaries and fonts come from a signed address. The engine, the shaping and subsetting binaries and every bundled font are downloaded from the asset store named in the license, by digest, verified and cached in the browser. No credential is sent; the digests built into the SDK decide what is accepted. Nothing runs that we did not publish — unless the license includes self-hosting, which unlocks engineUrl, shaperUrl, subsetterUrl and fonts.fontsBaseUrl (see Engine, binaries and fonts).

What the check sends. The license token; a one-time random value the answer must repeat, so an old answer cannot be replayed; the time; a pseudonymous identifier for this browser, created once and kept in it, which lets the licensing service count how many people use a license and tell a copied token apart (Chrome before 137 and Safari before 17 check without one); and troubleshooting facts about the SDK itself: its version and build, whether this is init or setLicense, where the token came from (passed in, found in the page, or remembered), how long ago the last answer was signed, whether the previous checks failed and why, and the code of any fatal SDK error. The browser adds the page's origin, as it does to every request. Never the document, its text, filenames, field values, or anything about the person. Offline licenses send nothing at all.

The watermark is part of the picture. It is one mark for every state that is not a clean production license — a development key, an unverified, paused or missing license: the Kernstra lockup, centered on the page at about two fifths of its width, in brand blue at low opacity. On screen, in thumbnails and in print, the mark is part of the page image, not an overlay. In exported files it is part of each page's content and, above every annotation, a watermark annotation of its own: not a comment, so Acrobat's Comments pane does not list it, locked and read-only, with the mark's opacity as its own. Which state the mark stands for, getLicense() and licenseChanged say.

Features are licensed individually. A license's signed features claim decides what runs: edit-text (Edit Text: retyping the document's own text), add-text (Add Text: writing new text into the page; a text box on top of it is the Text box tool, under annotations), edit-pages (changing the document's structure: Manage Pages and the thumbnails' page actions, doc.pages.insert() / delete() / move() / rotate() / setRotation() / duplicate() / import(), and editing bookmarks and attachments, in their panels and through doc.outline / doc.attachments), annotations (every drawing, mark-up, note, link, stamp, image, signature and erase tool — Image, Sign and Black out write into the page itself, and Erase can remove the page's own content — and creating, changing, deleting and flattening through doc.annotations; replying and review status in the Comments panel and the API. Listing annotations and reading comments need no feature, their words blanked without extract; a form field's widget changed, removed or flattened through doc.annotations needs forms too), forms (filling fields, doc.forms.setValues; without it fields are shown read-only), export (download, blob, buffer, base64, upload, doc.save(), page extraction, and password protection: doc.security.protect() / removeProtection() and the Security tool), print, and extract — reading the document's content out through the API: doc.text.extract() and doc.text.search(), doc.forms.fields() and doc.forms.getValues(), doc.attachments.data() and the attachments panel's download, viewer.search(), and beginEdit and setText on viewer.tools.editText (but for removing a block); and, blanked to '' without it, the text of the blocks viewer.tools.editText.listBlocks() returns, the contents and author of doc.annotations.list() and comments() and a web link's uri, the title, author, subject and creator of doc.metadata, the titles of doc.outline.get(), the names of doc.attachments.list(), the reasons of doc.signatures.list(), the values of doc.forms.getValues() (which throws without it), and the field values — with the options and export values that name them, and the options' labels — the onField* callbacks, onSignatureRequest and formFieldChanged carry. No watermark can mark data, so it is a feature of its own. The viewer still shows what it must to work — each field and its value in the field, a block's words in the Edit Text editor, a comment's words and author in the Comments panel, the bookmarks, attachments and signatures in theirs — and that is readable by a script on the page: extract closes every way the SDK hands content out, not the screen. (A license with export hands out the file, whose text any PDF library reads.) Tools outside the license never appear in the toolbar; activateTool and the APIs refuse them with FEATURE_NOT_LICENSED (see Errors). Viewing, navigation, zoom, thumbnails, reading the outline and the search panel need no feature; the text layer that makes a page's words selectable needs extract or clean output (see Accessibility). A viewer follows the rights of its document: when they grow (a confirmed license, an updated key with more features) the toolbar is rebuilt at once; a feature taken away reaches the next document opened.

What decides what a user can do. Five things stack, each able only to take away what the one above allows: the license state (whether documents open, and whether output carries the watermark), the token's signed features list (which tools, viewer actions and API calls run), the plan (the keys of every plan are minted with every feature; the SDK never sees it: plan is a label), the document's own permissions (print, fill, annotate, change pages, even under a license that has the feature), and your configuration (mode, which tools and buttons show — it can switch things off, never on). So "can a user print?" is: the license is valid, the token has print, the PDF allows printing, and the app did not hide the button.

Without any feature (a valid license is enough): open and render documents, navigate, zoom, thumbnails, the outline panel, the attachments list, reading comments (the Comments panel read-only, doc.annotations.list / get / comments with their words blanked without extract), search from the toolbar, Select and Pan, undo and redo. On a production license, copying text, the selection bar's Search for this and the accessible text layer too (see extract). Nothing that changes the document is in this list. extract exists because a watermark cannot mark text that leaves the viewer as data: everything it gates hands the document's words to the host's code.

Every plan carries every feature. Kernstra's licensing service mints the features list into each key, and every plan carries all of them, Edit Text and Add Text included: a larger plan adds users and apps, not tools. The SDK does not know a plan. It enforces the signed features list only, so a key behaves exactly as its features claim says. extract is on every production key, trials included (their output is clean and they expire), and on paying organizations' development keys; free and trial-only organizations' development keys go without it: a safety catch against using an unpaid key as a text-extraction service, since no watermark can mark extracted text.

License state. if (state.valid) state.features.includes('export') type-checks: the valid half has every field, the invalid half the reason. LicenseState, LicenseStateValid, LicenseStateInvalid, LicenseFeature, LicenseInvalidReason and LicensePauseReason are exported. A valid license fills env, plan, features (the features this SDK version enforces that the license names, in a fixed order) and offline, and expiresAt when the license has an expiry date: an offline license always does, an online one normally does not (the licensing API decides). An invalid one fills reason, one of the values in the LicenseInvalidReason table.

A paused license carries pauseReason, and the popup says why — its text begins with the sentence in the pauseReason table, followed by "A valid license is required to use this PDF SDK." (the LICENSE_REQUIRED error message reads the same). other stands for any reason this SDK version does not know, so a newer licensing API never breaks an older SDK. A pause is not permanent: the same key loads cleanly again once the license is back in force.

getLicense(), onLicenseChange and licenseChanged report a verdict as soon as it arrives; documents already open keep the rights they were opened with (see "Never mid-work" above). The "license required" popup follows loads, not verdicts: it appears when a load meets a license that does not load — at init, any invalid reason but stale; at setLicense or opening a document, any but stale and no-license (signing out stops nothing that is open) — such as revoked, paused, expired, a copied token, a host it does not name, or a malformed or forged key; it is lifted by the next load whose license loads (valid, or unverified and watermarked). While documents opened earlier are still open, it has a Close button: those documents keep working; the one the popup is about stays watermarked and its features refused.

Kernstra.destroy() removes the onLicenseChange subscriptions, like every other SDK listener; one made before an init() refused for want of a license stays, so it sees the retry.

Important: Kernstra.init() must complete before calling PDFDocument.fromUrl(), PDFDocument.fromFile(), PDFDocument.fromBytes(), or PDFDocument.fromUrlProgressive(). All four need init() to have finished: initialize once at application startup, then load documents as needed.