Licensing
Every TruPDF deployment carries a signed license token. The SDK verifies it at init, binds the feature gate, and refuses to construct premium engines without the corresponding entitlement. This page covers what the tiers include, how the trial works, what the runtime does, and how to handle the lifecycle events your app cares about.
Tiers
| Tier | Issued by | Watermark | Expiry behaviour | Features included by default |
|---|---|---|---|---|
trial | trupdf.truenotech.com/signup self-serve | Yes | Hard cliff at 15 days | (none) — viewer + basic annotations only |
pro | Email [email protected] | No | Daily reminder for 30 days before exp, then read-only | redaction, sign, convert, compare, collab, ocr, threed, ai |
enterprise | Email [email protected] | No | Daily reminder for 30 days before exp | all pro features + offline mode (no heartbeats) + SSO hooks + audit-log export |
Watermark is the locked decision for trial: it sits on every rendered
canvas AND inside the content stream of every saved / exported PDF
(not an /Annot — survives in Acrobat, Chrome, Firefox, Edge,
Preview). Pro / enterprise saves are watermark-free.
How a license flows through your app
LicenseManager.init(tokenString)
│
├─► verifyLicense() ed25519 signature, version, exp, iat
├─► matchesDomain() location.hostname vs token.domains[]
├─► RevocationCache is this jti in the cached revoke list?
├─► FeatureGate.bind() premium engine ctors now succeed
│
├─► Heartbeat.start() trial: 6h, paid: 7d, enterprise: skipped
│ └─► onResponse updates RevocationCache, exp checks
│ └─► onRevoked flips status to 'revoked', unbinds gate
│
├─► scheduleExpiry setTimeout fires at exp → 'expired'
├─► scheduleExpiringSoon 30 days before exp, daily warnings
├─► scheduleHeartbeatStale fires if no successful ping in 14d
│
└─► trial only:
├─► TrialExpiryGuard attaches; hard overlay on 'expired'
└─► WatermarkOverlay paint + content-stream burn pre-installedIn production a host writes exactly:
const manager = new LicenseManager();
await manager.init(licenseToken);
<DocViewer source="…" licenseManager={manager} />;Everything below that — watermark plumbing, expiry guard, heartbeats, revocation feed — is automatic.
Subscribing to license events
Use manager.on(type, handler) for any of the typed events:
manager.on('LicenseReady', ({ entitlement }) => {
console.info('TruPDF licensed', entitlement.tier, entitlement.customerId);
});
manager.on('LicenseExpiringSoon', ({ daysLeft }) => {
if (daysLeft <= 14) showRenewalBanner({ daysLeft });
});
manager.on('LicenseExpired', () => {
// Pro: read-only mode + banner. Trial: hard overlay (auto-installed).
toast.error('Your TruPDF license has expired. Contact [email protected].');
});
manager.on('LicenseRevoked', ({ jti }) => {
reportToSentry({ msg: 'TruPDF license revoked mid-session', jti });
});
manager.on('HeartbeatStale', ({ ageMs }) => {
// No successful ping in 14+ days. Customer's firewall? Outage?
console.warn('TruPDF heartbeat stale', ageMs);
});All handlers receive a typed payload. Subscription returns an unsubscribe function:
const off = manager.on('LicenseExpired', handler);
// Later
off();Domain matching
Every license carries a domains[] glob list. At init time the SDK
checks location.hostname against it. The matcher rules:
acme.com— exact match only.*.acme.com— matchesacme.comitself AND any subdomain (so one glob coversapp.acme.com,staging.acme.com, etc.).localhost,127.0.0.1,::1are always allowed regardless of the glob list. Local dev never breaks.- The bare
*is forbidden — we never wildcard-match every host.
If you need to whitelist multiple unrelated domains (e.g. you run on
both acme.com and acme-corp.de), email sales — we re-issue with
both globs in domains[].
Feature gate
Every premium engine calls gate.require('<feature>') in its
constructor:
class RedactionEngine {
constructor() {
gate.require('redaction');
// …
}
}If the bound entitlement doesn't include redaction, construction
throws LicenseError with code: 'FEATURE_NOT_LICENSED'. UIs can
check ahead of time with gate.has('redaction') to hide buttons.
import { gate } from '@trupdf/viewer';
{gate.has('redaction') && (
<button onClick={openRedactionTool}>Redact</button>
)}Feature names match exactly:
redaction, sign, convert, compare, collab, ocr, threed, ai
The compare package is an exception: the gate is on each public
function (diffWords, diffLines, visualDiff) instead of a
constructor. changesOnly() is intentionally not gated — it's a
pure filter on an already-gated result.
Trial behaviour
Self-serve at trupdf.truenotech.com/signup. The form accepts a work or personal email + a company/app domain glob (optional with a work email — it defaults to the email's domain; required with a personal email, since the license binds to the domain).
What you get:
- 15-day token.
features: []— the viewer + basic annotation tools work; every premium engine (redaction,sign,convert, …) refuses to construct.- Diagonal
"TruPDF Trial — Not for Production"watermark painted on every rendered page at 18% opacity, -30° rotation. - Watermark burned into every saved / exported PDF via a
content-stream overlay (not
/Annot— survives every viewer). - Hard cliff at expiry. A non-dismissible full-viewport overlay
appears the moment
exppasses; SDK methods reject. Mid-edit work is interrupted — by design.
What we block:
- Free / disposable email providers (gmail, outlook, hotmail, yahoo,
iCloud, AOL, ProtonMail, mailinator, …) get
400 work_email_required. - 3 trials per email or per IP per hour.
To upgrade a trial customer to paid, just issue them a fresh pro
token via the admin CLI — the old trial stays valid until its exp,
which means no service interruption while they swap.
Renewals
We send a renewal email at exp - 30 days. To prep manually:
node services/license-server/dist/cli.js expiring --within 30To issue a renewal:
node services/license-server/dist/cli.js issue \
--customer acme-corp --email [email protected] \
--tier pro --domains "*.acme.com" \
--features redaction,sign,convert,compare,collab,ocr,threed,ai \
--years 1This produces a fresh jti. The customer drops the new token in
place of the old one — LicenseManager.init() accepts it without
any state migration. The old license stays valid until its own
exp, so there's no cutover window.
Revocation
If a PAT leaks or a customer churns, revoke immediately:
node services/license-server/dist/cli.js revoke \
--jti 56546fa5-a4a8-46f0-a7a4-25b2ce708aad \
--reason "PAT leaked to public repo"The SDK picks up the revocation on the next heartbeat — within 6h
for trial, 7d for paid — or on the next load, when it calls
/v1/revocations/check for its own license. Air-gapped (--offline)
licenses skip both, so they can't be revoked once issued.
Also revoke the GitHub Packages PAT in the GitHub UI. Without that, the customer can still re-download the SDK — combined with a revoked license they can only run the unlicensed path, but you don't want both halves of the lock outstanding.
Offline / air-gapped
Enterprise customers can request --offline. The token's hb field
becomes 'none', and the SDK skips heartbeats entirely. Pros:
- No outbound traffic from the customer's deployment.
- Works in fully isolated networks.
Cons:
- No revocation. The SDK skips the
/v1/revocations/checkcall, so an offline license can't be revoked once issued — use a shortexpand re-issue instead. - No telemetry on usage (feature-construction counts). Renewal conversations have to ask, not look.
Document this trade-off in the contract before issuing.
What the SDK never does
- Never block on heartbeat failure. Network errors are silent.
Only an explicit
revoked: trueresponse from the server flips state to revoked. - Never persist the raw token to localStorage. Hosts are
responsible for delivering the token to
manager.init. We don't cache it. - Never log the token to console or telemetry. Audit data
records the
jtionly. - Never auto-renew. Renewals are intentionally manual so churn doesn't surprise either side.
Errors
LicenseManager.init throws LicenseError on any verification or
policy failure. code field is one of:
| code | Meaning |
|---|---|
LICENSE_MISSING | Empty / null token. |
PAYLOAD_MALFORMED | Token doesn't parse — likely truncated in transit. |
SIGNATURE_INVALID | Token signature doesn't match the embedded public key. SDK and server are out of sync. |
UNSUPPORTED_VERSION | Token's payload version is newer than this SDK build. |
EXPIRED | Past exp. |
NOT_YET_VALID | iat is in the future beyond the 5-minute skew tolerance. |
WRONG_DOMAIN | location.hostname not in domains[]. |
REVOKED | jti was on the revocation list at init. |
FEATURE_NOT_LICENSED | A premium engine tried to construct without the corresponding feature. |
NOT_BOUND | gate.require(...) called before manager.init() resolved. |
TAMPERED | Bundled public key is malformed or missing — SDK build is broken. |
Catch + surface with a useful message:
try {
await manager.init(token);
} catch (err) {
if (err instanceof LicenseError) {
if (err.code === 'EXPIRED') showRenewalDialog();
else if (err.code === 'WRONG_DOMAIN') showWrongDomainDialog();
else reportToSentry({ code: err.code });
}
throw err;
}