TPTruPDF

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

TierIssued byWatermarkExpiry behaviourFeatures included by default
trialtrupdf.truenotech.com/signup self-serveYesHard cliff at 15 days(none) — viewer + basic annotations only
proEmail [email protected]NoDaily reminder for 30 days before exp, then read-onlyredaction, sign, convert, compare, collab, ocr, threed, ai
enterpriseEmail [email protected]NoDaily reminder for 30 days before expall 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-installed

In 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 — matches acme.com itself AND any subdomain (so one glob covers app.acme.com, staging.acme.com, etc.).
  • localhost, 127.0.0.1, ::1 are 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 exp passes; 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 30

To 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 1

This 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/check call, so an offline license can't be revoked once issued — use a short exp and 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: true response 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 jti only.
  • 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:

codeMeaning
LICENSE_MISSINGEmpty / null token.
PAYLOAD_MALFORMEDToken doesn't parse — likely truncated in transit.
SIGNATURE_INVALIDToken signature doesn't match the embedded public key. SDK and server are out of sync.
UNSUPPORTED_VERSIONToken's payload version is newer than this SDK build.
EXPIREDPast exp.
NOT_YET_VALIDiat is in the future beyond the 5-minute skew tolerance.
WRONG_DOMAINlocation.hostname not in domains[].
REVOKEDjti was on the revocation list at init.
FEATURE_NOT_LICENSEDA premium engine tried to construct without the corresponding feature.
NOT_BOUNDgate.require(...) called before manager.init() resolved.
TAMPEREDBundled 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;
}