From npm install to an embedded PDF viewer in under a minute.
One package, one call to initialize, one element on your page: the Kernstra Web SDK, a JavaScript PDF viewer and editor, running in the browser. This guide takes you from a blank project to a working viewer with the tools you choose, and shows you where every next step lives.
Four things, and you probably have them all.
localhost. On plain HTTP, including a phone opening your dev server by its LAN address, init rejects.80vh, or 100dvh on phones. Forgetting it is the classic first mistake.Two ways to embed the PDF viewer. The same viewer at the end of each.
Pick the one that matches your project. The snippets here are complete files: they copy with one click and run on a development key, which the next section shows you how to get.
$ npm install @kernstra/pdf-web-sdk
# Your key, from the console. Swap in the production key later.
KERNSTRA_LICENSE=<your key>import { defineConfig } from 'vite'; // Add these to the config you have (keep its plugins). export default defineConfig({ // Let browser code read KERNSTRA_* variables. To type it, add // interface ImportMetaEnv { readonly KERNSTRA_LICENSE: string } // to src/vite-env.d.ts. envPrefix: ['VITE_', 'KERNSTRA_'], // The SDK needs ES2022 or later, and so does top-level await. // Vite 5 to 7 default to older targets; Vite 8's default fits. build: { target: 'es2022' }, });
import { Kernstra, PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk'; // 1. Once per page load. Checks your key and loads the engine. await Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE, }); // 2. Open a document. Nothing about it leaves the browser. const doc = await PDFDocument.fromUrl('/reports/q3-board-pack.pdf'); // 3. Give it an element with a height. Options are optional: // here, the ribbon toolbar instead of the default bar. const container = document.getElementById('viewer')!; new PDFViewer(container, doc, { toolbar: { layout: 'ribbon' }, });
<div id="viewer" style="height: 80vh"></div> <script type="module" src="/src/viewer.ts"></script>
- 1Initialize once per page. A second call with the same options returns the same promise, so a hot reload or a second viewer costs nothing.
- 2Open from a URL, a File or bytes. A large file can start opening after its first few hundred kilobytes. See Loading documents.
- 3The viewer owns one element. Its interface lives in that container; only its stylesheets and its dialogs sit outside, in the head and the body. It sizes itself from the container, not the window, so it works in a narrow column too. The container must be in the main document, not a shadow root.
init downloads it once per browser and SDK version, checks it and caches it. Your bundler only sees ordinary JavaScript. Bundlers and hosting has the details.// The one module that owns the SDK's options. Nothing runs // until the first call; every later call gets the same promise. // In vite.config.ts, add envPrefix: ['VITE_', 'KERNSTRA_']. import { Kernstra } from '@kernstra/pdf-web-sdk'; export const initKernstra = () => Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE });
import { useEffect, useRef } from 'react'; import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk'; import { initKernstra } from '../lib/kernstra'; export function DocumentViewer({ url }: { url: string }) { const ref = useRef<HTMLDivElement>(null); useEffect(() => { let canceled = false; let doc: PDFDocument | undefined; let viewer: PDFViewer | undefined; (async () => { await initKernstra(); // safe on every mount const opened = await PDFDocument.fromUrl(url); if (canceled) { await opened.close(); return; } doc = opened; viewer = new PDFViewer(ref.current!, doc, { toolbar: { layout: 'ribbon' }, }); })().catch(console.error); return () => { canceled = true; viewer?.destroy(); void doc?.close(); }; }, [url]); // a new url opens a new document return <div ref={ref} style={{ height: '80vh' }} />; }
// Owns the SDK's options; nothing runs until the first call. // In next.config.ts, add to your config so the browser bundle has it: // env: { KERNSTRA_LICENSE: process.env.KERNSTRA_LICENSE }, import { Kernstra } from '@kernstra/pdf-web-sdk'; export const initKernstra = () => Kernstra.init({ license: process.env.KERNSTRA_LICENSE ?? '' });
'use client'; import dynamic from 'next/dynamic'; // The React tab's component; its ../lib/kernstra import is // lib/kernstra.ts, which reads process.env.KERNSTRA_LICENSE. // It needs the browser, so it never renders on the server. export const DocumentViewer = dynamic( () => import('@/components/DocumentViewer').then((m) => m.DocumentViewer), { ssr: false, loading: () => <div style={{ height: '80vh' }} /> }, );
import { Suspense } from 'react'; import { DocumentViewer } from './viewer'; type Params = Promise<{ id: string }>; // New apps prerender a static shell (cacheComponents), so // params is read inside <Suspense>, not at the top. export default function Page({ params }: { params: Params }) { return ( <Suspense fallback={<div style={{ height: '80vh' }} />}> <Viewer params={params} /> </Suspense> ); } async function Viewer({ params }: { params: Params }) { const { id } = await params; return <DocumentViewer url={`/api/documents/${id}`} />; }
// The one module that owns the SDK's options. Nothing runs // until the first call; every later call gets the same promise. // In vite.config.ts, add envPrefix: ['VITE_', 'KERNSTRA_']. import { Kernstra } from '@kernstra/pdf-web-sdk'; export const initKernstra = () => Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE });
<!-- Opens its url once: give it :key="url" to switch documents. --> <script setup lang="ts"> import { onBeforeUnmount, onMounted, ref } from 'vue'; import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk'; import { initKernstra } from '../lib/kernstra'; const props = defineProps<{ url: string }>(); const el = ref<HTMLDivElement>(); let doc: PDFDocument | undefined; let viewer: PDFViewer | undefined; let unmounted = false; async function open() { await initKernstra(); const opened = await PDFDocument.fromUrl(props.url); if (unmounted) return opened.close(); doc = opened; viewer = new PDFViewer(el.value!, doc, { toolbar: { layout: 'ribbon' }, }); } onMounted(() => { open().catch(console.error); // log a failed load, keep the page }); onBeforeUnmount(() => { unmounted = true; viewer?.destroy(); void doc?.close(); }); </script> <template> <div ref="el" style="height: 80vh" /> </template>
// Owns the SDK's options. nuxt.config.ts: // runtimeConfig: { public: { kernstraLicense: '' } } // Nuxt fills it from NUXT_PUBLIC_KERNSTRA_LICENSE: in .env for // nuxi dev (Nuxt skips .env.local), in the server's environment // in production. import { Kernstra } from '@kernstra/pdf-web-sdk'; // Call it in setup; await what it returns once mounted. export const useKernstra = () => { const { kernstraLicense } = useRuntimeConfig().public; return () => Kernstra.init({ license: kernstraLicense }); };
<!-- The .client suffix keeps it out of server rendering. --> <script setup lang="ts"> import { onBeforeUnmount, onMounted, ref } from 'vue'; import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk'; const initKernstra = useKernstra(); // auto-imported composable const props = defineProps<{ url: string }>(); const el = ref<HTMLDivElement>(); let doc: PDFDocument | undefined; let viewer: PDFViewer | undefined; let unmounted = false; async function open() { await initKernstra(); const opened = await PDFDocument.fromUrl(props.url); if (unmounted) return opened.close(); doc = opened; viewer = new PDFViewer(el.value!, doc, { toolbar: { layout: 'ribbon' }, }); } onMounted(() => { open().catch(console.error); // log a failed load, keep the page }); onBeforeUnmount(() => { unmounted = true; viewer?.destroy(); void doc?.close(); }); </script> <template> <div ref="el" style="height: 80vh" /> </template>
import { Injectable } from '@angular/core'; import { Kernstra } from '@kernstra/pdf-web-sdk'; // After `ng generate environments`, both environment files hold: // declare const KERNSTRA_LICENSE: string; // export const environment = { kernstraLicense: KERNSTRA_LICENSE }; // and the CLI fills it in from your variable: // ng serve --define "KERNSTRA_LICENSE='$KERNSTRA_LICENSE'" import { environment } from '../environments/environment'; // Owns the SDK's options; nothing runs until init() is first called. @Injectable({ providedIn: 'root' }) export class KernstraService { init() { return Kernstra.init({ license: environment.kernstraLicense }); } }
import { Component, ElementRef, afterRenderEffect, inject, input, viewChild, } from '@angular/core'; import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk'; import { KernstraService } from './kernstra.service'; @Component({ selector: 'app-document-viewer', template: `<div #host style="height: 80vh"></div>`, }) export class DocumentViewer { readonly url = input.required<string>(); private readonly host = viewChild.required<ElementRef<HTMLElement>>('host'); private readonly kernstra = inject(KernstraService); constructor() { // Browser only, so server rendering never runs it. Runs again when // url changes; the cleanup runs first, and on destroy. afterRenderEffect((onCleanup) => { const url = this.url(); const host = this.host().nativeElement; let doc: PDFDocument | undefined; let viewer: PDFViewer | undefined; let canceled = false; onCleanup(() => { canceled = true; viewer?.destroy(); void doc?.close(); }); void (async () => { await this.kernstra.init(); const opened = await PDFDocument.fromUrl(url); if (canceled) return opened.close(); doc = opened; viewer = new PDFViewer(host, opened, { toolbar: { layout: 'ribbon' }, }); })(); }); } }
// The one module that owns the SDK's options. Nothing runs // until the first call; every later call gets the same promise. // In vite.config.ts, add envPrefix: ['VITE_', 'KERNSTRA_']. import { Kernstra } from '@kernstra/pdf-web-sdk'; export const initKernstra = () => Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE });
<script lang="ts"> import { onMount } from 'svelte'; import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk'; import { initKernstra } from './kernstra'; // Opens its url once: wrap it in {#key url} to switch documents. let { url }: { url: string } = $props(); let el: HTMLDivElement; onMount(() => { let destroyed = false; let doc: PDFDocument | undefined; let viewer: PDFViewer | undefined; (async () => { await initKernstra(); const opened = await PDFDocument.fromUrl(url); if (destroyed) return opened.close(); doc = opened; viewer = new PDFViewer(el, doc, { toolbar: { layout: 'ribbon' } }); })().catch(console.error); return () => { destroyed = true; viewer?.destroy(); void doc?.close(); }; }); </script> <div bind:this={el} style="height: 80vh"></div>
<script lang="ts"> import { page } from '$app/state'; // SvelteKit 2: '$lib/DocumentViewer.svelte' import DocumentViewer from '#lib/DocumentViewer.svelte'; </script> <!-- The Svelte tab's two files, unchanged: onMount runs only in the browser. The key mounts a fresh viewer when the id changes. --> {#key page.params.id} <DocumentViewer url={`/api/documents/${page.params.id}`} /> {/key}
- 1One module owns
init. Each tab’skernstra.ts(a composable in Nuxt, a service in Angular) holds the SDK’s options and runs nothing until it is called, so importing it is safe on a server. Every later call gets the same promise, so StrictMode, hot reload and a second viewer need no guard. - 2Destroy on unmount, close what you opened.
viewer.destroy()releases the viewer anddoc.close()the document, including one that finishes opening after the component is gone. React and Angular reopen on a newurl; Vue and Svelte open theirs once, so key them by it to switch documents. - 3The key from the environment. Every tab reads
KERNSTRA_LICENSEthe way its framework exposes variables to the browser:envPrefixin Vite,envin Next.js,runtimeConfigin Nuxt (filled fromNUXT_PUBLIC_KERNSTRA_LICENSE),--defineintoenvironment.tsin Angular. It is not a secret: it ships to the browser, and a production key runs only on the domains it names.
afterRenderEffect runs only in the browser.A development key is free, and it takes a minute.
It runs on any host, your laptop, a CI runner or a preview deployment, as long as the page is on HTTPS or localhost. Every page it renders, prints or exports carries the Kernstra watermark, so development output can never ship by mistake. A free organization’s development key also leaves out reading content through the API (extracting and searching text, form values) and copying text. When you are ready to ship, request a production license in the same console: Kernstra replies within one business day, and the production key runs on your domains.
Create your free key in the console (opens in a new tab)
Build a PDF viewer step by step, as you read.
Six short steps. The preview changes with the step you are on, so you can see what each line adds before you write it.
Start the engine.
One call per page load. It checks your key, then loads the rendering engine and makes sure it is exactly the file this SDK version expects. A browser downloads the engine once and keeps it, so later loads start from the cache. Nothing is on screen yet.
import { Kernstra } from '@kernstra/pdf-web-sdk'; await Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE, }); Kernstra.isInitialized(); // true Kernstra.version; // '1.0.0'
Open the file.
From a URL here; other sources are under Loading documents. A document is independent of any viewer: you can read its page count, change its pages or protect it without ever showing it. Once the file is in, opening reads its structure, not every page, so even a large one opens quickly.
import { PDFDocument } from '@kernstra/pdf-web-sdk'; const doc = await PDFDocument.fromUrl( '/reports/q3-board-pack.pdf', ); doc.pageCount; // 48
Put it on the page.
Any element with a height. With no configuration you get the full toolbar, the navigation bar and the page thumbnails. The viewer measures the container, not the window, so it also works inside a narrow column or a split view.
import { PDFViewer } from '@kernstra/pdf-web-sdk'; const container = document.getElementById('viewer')!; const viewer = new PDFViewer(container, doc);
Keep only what this screen needs.
Every tool is a switch, on unless you turn it off. A review screen might want highlight, note, sign and search, and nothing that changes the document's text. Turn the rest off and the toolbar shrinks to fit. Print and download are their own switches.
import { PDFViewer, TOOL_NAMES } from '@kernstra/pdf-web-sdk'; // Four tools, plus Select and Pan to read with. const keep = new Set([ 'selectText', 'pan', 'highlight', 'note', 'sign', 'search', ]); const tools = Object.fromEntries( TOOL_NAMES.map((t) => [t, keep.has(t)]), ); // Replaces the viewer from step 3. const viewer = new PDFViewer(container, doc, { tools, toolbar: { print: true, download: false }, });
Know what the person is doing.
The viewer emits events for navigation, zoom, form fields, signatures and exports. Wire them to your own state, your analytics or your validation. Undo and redo are one history across every tool, and you can drive them from your own buttons.
// setPage, validate, markSigned, undoButton: your code. viewer.on('pageChanged', ({ pageIndex }) => setPage(pageIndex + 1)); viewer.on('formFieldChanged', ({ field }) => validate(field.name, field.value)); viewer.on('signatureCaptured', ({ pageIndex }) => markSigned(pageIndex)); undoButton.onclick = () => viewer.undo();
Get the bytes back.
Saving produces a standard PDF with every edit written in, and it happens in the browser. Ask the viewer for the file from your own button, or take over the toolbar's Download button and send it where it belongs.
// Step 4's viewer, with Download back on // and running your save instead. const viewer = new PDFViewer(container, doc, { tools, callbacks: { onDownload: () => void save() }, }); // Everything on screen, every edit written in. async function save() { const res = await viewer .createExporter() .uploadTo('/api/documents/417'); // a PUT if (!res.ok) showSaveFailed(); }
Open a PDF from a URL, a file, a large file, or a locked one.
A document opens the same way whatever its source, and it never leaves the browser to be opened.
Any URL your page can fetch. Same-origin needs nothing; cross-origin needs an Access-Control-Allow-Origin header for your page.
const doc = await PDFDocument.fromUrl( '/reports/q3-board-pack.pdf', );
Straight from an input or a drop event. The bytes stay in memory on the device.
let doc: PDFDocument | undefined; let viewer: PDFViewer | undefined; let latest: File | undefined; input.addEventListener('change', async () => { const file = input.files?.[0]; if (!file) return; latest = file; viewer?.destroy(); // one viewer at a time viewer = undefined; const old = doc; doc = undefined; await old?.close(); const opened = await PDFDocument.fromFile(file); if (file !== latest) return opened.close(); // a newer pick won doc = opened; viewer = new PDFViewer(container, doc); });
The first pages open after a small part of the file has arrived, and the rest streams in behind it, 256 KiB at a time. Until all of it is there, people can read and fill in forms; editing waits. The server must answer HTTP range requests, and most do.
const doc = await PDFDocument.fromUrlProgressive( '/scans/site-survey-2026.pdf', { onProgress: (loaded, total) => setProgress(loaded / total) }, );
Pass it as the password option when you open the file. A missing or wrong one rejects with PASSWORD_ERROR: ask in your own dialog, or the SDK’s createPasswordPrompt, and try again.
try { doc = await PDFDocument.fromUrl(url, { password }); } catch (e) { if (e instanceof SDKError && e.code === ErrorCode.PASSWORD_ERROR) { askForPassword(); // then open it again } }
Five settings most teams change in the first hour.
Everything on screen is configurable, and everything can be removed. These are the ones that come up first.
new PDFViewer(container, doc, { theme: { mode: 'auto', // light · dark · auto accentColor: '#2F6BFF', // your brand color }, });
Light, dark, or follow the system.
One accent color reaches every button, focus ring and selection. For deeper changes, every color, radius and font is a design token you can override, and the main regions carry stable class names for placing, sizing or hiding them.
- No badge or watermark on a production key.
- Icons can be swapped one by one through the toolbar configuration.
new PDFViewer(container, doc, { toolbar: { layout: 'ribbon', // bar · ribbon · rail · none groups: ['annotate', 'review', 'pages'], actions: [{ id: 'send', label: 'Send for approval', icon: 'done', variant: 'primary', onClick: send, }], }, sidebar: { attachments: false }, navigation: { zoomControls: false }, });
A bar across the top, a ribbon, a rail down the side, or nothing.
Choose which tool groups appear (on the ribbon, in your order) and add your own actions beside them, so the person never leaves the document to do the next thing.
- On a narrow container, the tools that do not fit fold into a More menu.
- The sidebar's Pages, Outline, Attachments, Signatures and Comments panels are switches too.
new PDFViewer(container, doc, { layout: { phone: 'sheet', // sheet · dock · split · float · none tablet: 'rail', // rail · none pointer: 'auto', // 'coarse' previews touch on a desktop }, });
Measured from the container, not the viewport.
In a phone-sized container the toolbar becomes a sheet, a dock, a split control or a floating cluster, and the same tools keep working, including editing text with a finger.
- On a touch screen every control grows to a 44px target, and controls a mouse reveals on hover are always shown.
- Breakpoints are yours to override.
new PDFViewer(container, doc, { locale: { lang: 'de', translations: { de: { 'cluster.highlight': 'Markieren', // the toolbar button 'tool.sign': 'Unterschreiben', 'common.pageCount': '{n, plural, one {# Seite} other {# Seiten}}', }, }, // Your own words, whatever the language. strings: { 'common.download': 'Save a copy' }, }, });
Every label is a string you can replace.
The SDK ships English; give it your translations key by key, or rename a tool to match your own vocabulary. Plurals follow each language's own rules.
- Full keyboard access and screen-reader announcements are on by default.
- Arabic, Hebrew and Persian documents render and search normally; the viewer's own layout stays left to right.
// No viewer. Just the document. await doc.pages.insert({ position: { after: 0 } }); await doc.pages.rotate(2, 90); // Search reads text out, so it needs the extract feature. const [hit] = await doc.text.search('termination'); if (hit) { await doc.annotations.create(hit.pageIndex, { type: 'note', rect: { x: 500, y: 575, width: 20, height: 20 }, contents: 'Confirm the response window.', }); } await doc.security.protect({ ownerPassword, // from your server, not your bundle permissions: { modify: false }, }); const bytes = await doc.save();
The SDK API works without any interface on screen.
Pages, annotations, search, security and saving are all callable from your own code. Editing existing text needs a viewer, and so do Add Text, signatures, images and stamps, which are placed through the interface.
- A viewer attached to the same document re-renders and records undo automatically.
- The SDK API reference has a matrix of every tool and how much of it is reachable from code.
Any bundler that reads package exports. No files to host.
The package is ordinary ES2022 JavaScript with TypeScript types. The engine is not part of it: on every load, init makes sure the engine is exactly the file this SDK version expects, so a swapped or truncated file fails with an error instead of running.
Rendering off the main thread
Pages render on the main thread by default. For large documents or slower phones, serve the render worker that ships in the package (dist/render.worker.js) from your own site and pass its URL to init as workerUrl. It is your own copy of a package file, so any license may set it, unlike the engine URLs that only self-hosting licenses may change. It makes no request of its own: it takes the engine from the page, which downloads it once. Viewing renders on the worker under every license, once a progressive file has fully arrived. Under a production license that includes export, with clean output, the worker also follows your edits; under any other license, a development key included, it stops at the first change to the document and pages render on the main thread from then on. Turn it off with performance: { workerEnabled: false }. The worker keeps a second copy of the engine in memory.
import { Kernstra } from '@kernstra/pdf-web-sdk'; // Copy the worker that ships in the package to your static files: // cp node_modules/@kernstra/pdf-web-sdk/dist/render.worker.js public/assets/ // With Vite, skip the copy: import workerUrl from // '@kernstra/pdf-web-sdk/render-worker?url' and pass workerUrl. export const initKernstra = () => Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE, workerUrl: '/assets/render.worker.js', });
Content security policy
If you set one, these are the directives the SDK needs; the docs' installation guide explains each line. Add the origin of any document you open to connect-src. The render worker's own response needs script-src 'self' 'wasm-unsafe-eval'. With a strict style-src, pass your nonce once and every stylesheet the SDK adds carries it; the policy also refuses style="…" in your own markup, so give the container its height in a stylesheet. Policies that enforce Trusted Types are not supported in 1.0.
script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self' <the two Kernstra origins your license names>; img-src 'self' data:; style-src 'self' 'nonce-<nonce>';
import { Kernstra } from '@kernstra/pdf-web-sdk'; // Or declare it once in <meta property="csp-nonce" nonce="…">. export const initKernstra = () => Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE, styleNonce: nonceFromYourServer, // on every stylesheet the SDK adds });
Seven common problems, and the fix for each.
The viewer is as tall as the whole document, or the page scrolls instead of it
The container has no height, so the viewer grows to fit the whole document and the page scrolls instead. Give it one: percent heights need a sized parent, a viewport unit is the reliable choice, and a flex or grid child also needs min-height: 0. Leave its overflow alone.
#viewer { height: 80vh; } /* or 100dvh on phones, or a sized flex child */init rejects with ENGINE_LOAD_FAILED
The engine could not be downloaded or checked. A browser’s first load needs the network. If you set a content security policy, check that it allows 'wasm-unsafe-eval' and the two origins your license names in connect-src, as in Bundlers and hosting.
The whole file downloads before the first page appears
Use fromUrlProgressive: fromUrl always downloads the whole file. Progressive opening needs the server to answer range requests with 206 Partial Content; one that ignores them gets a normal full download. Across origins, CORS must also expose Content-Range, or the file does not open.
Access-Control-Expose-Headers: Content-Range, Accept-Ranges, Content-Length
There is a watermark on every page
You are on a development key, which marks every page on screen, in print and in exports. That is by design: switch the environment variable to your production key. On a production key, a watermark means the license could not be confirmed, for 48 hours or ever on this browser: check that your connect-src and any proxy allow its origins. Kernstra.getLicense() says why.
init rejects with LICENSE_REQUIRED
Kernstra.getLicense() reports the reason. no-license: the variable reached the browser empty; check that your bundler exposes it (Vite’s envPrefix, Next.js’s env), restart the dev server after editing .env, and set it where you deploy too. origin-mismatch: a production key runs only on the domains it names; ask for the host, including staging and preview hosts, in a support case, and the key you deployed picks it up by itself once it is added. insecure-context: use HTTPS or localhost.
A tool is missing, or a call rejects with FEATURE_NOT_LICENSED
Each license unlocks features: Edit Text, Add Text, page editing, annotations, forms, export, print, and reading content out through the API (extracting and searching text, form values). A tool outside yours is not available, and its API calls reject. Kernstra.getLicense() lists what yours includes.
Next.js says document is not defined
Something called Kernstra.init() or built a viewer on the server. Load the viewer component on the client only, with dynamic(…, { ssr: false }) as in the Next.js tab, and call init inside an effect. Nuxt and SvelteKit have the same cause; their tabs show their fix.
You have a viewer. Here is where the rest lives.
An engineer answers. Not a ticket queue.
Send the snippet that is not doing what you expect, and an engineer who works on the SDK replies. Development keys get email support too.
- Replies within one business dayUrgent cases first, by email and in the console.
- Send a document, get a diagnosisA file that renders oddly or edits wrongly is a bug we want. Share it privately and we will tell you what we see.
- ChangelogEvery release is listed with what changed.