React SDK
Use @korala/react to embed document signing and PDF template preparation in your application. Signing components also provide hooks for tracking events and controlling navigation.
Installation
npm install @korala/react
# or
yarn add @korala/react
# or
pnpm add @korala/reactQuick Start
import { KoralaSigner } from '@korala/react';
function SigningPage({ token }: { token: string }) {
return (
<KoralaSigner
token={token}
signingUrl="https://app.korala.ai"
style={{ width: '100%', height: '800px' }}
onSigned={(data) => {
console.log('Document signed!', data.documentId);
}}
onError={(data) => {
console.error('Signing error:', data.message);
}}
/>
);
}Components
KoralaSigner
Renders an embedded signing iframe. The signer views the document and completes all assigned fields without leaving your app, from typed text fields to drawn signatures. The signer fills in any field you create without a value, so your app doesn’t have to collect that data first. See Collecting Information from Signers.
<KoralaSigner
token={signingToken}
signingUrl="https://app.korala.ai"
className="signing-frame"
style={{ width: '100%', height: '100%' }}
allowedOrigins={['https://app.korala.ai']}
onReady={(data) => console.log('Ready:', data.version)}
onLoaded={(data) => console.log('Document loaded:', data.documentName)}
onViewed={(data) => console.log('Viewed at:', data.viewedAt)}
onFieldFilled={(data) => console.log(`${data.filledFields}/${data.totalRequiredFields} fields`)}
onSigned={(data) => console.log('Signed:', data.documentId)}
onDeclined={(data) => console.log('Declined:', data.reason)}
onError={(data) => console.error(data.message)}
/>Props
| Prop | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Signer access token (from the API) |
signingUrl | string | No | Base URL of the signing app. Defaults to https://korala.ai |
className | string | No | CSS class for the iframe |
style | CSSProperties | No | Inline styles for the iframe |
allowedOrigins | string[] | No | Restrict which origins can send events. Accepts all if omitted |
theme | KoralaSignerTheme | No | Customize accent color, radius, font, background, and (on white-label plans) a logo |
hideHeader | boolean | No | Hide the signer header (document name + “Signing as”) |
completionMessage | string | No | Custom message on the completion screen (max 200 chars) |
showNavigation | boolean | No | Show Korala’s page/field navigation inside the embed (off by default) |
hideAttribution | boolean | No | Remove the “Powered by Korala” footer. Requires a white-label plan |
onReady | (data) => void | No | Iframe initialized |
onLoaded | (data) => void | No | Document loaded in viewer |
ownConsent | boolean | No | Deprecated compatibility option that suppresses Korala’s disclosure and consent capture. See below |
onViewed | (data) => void | No | Signer viewed the document |
onConsentAccepted | (data) => void | No | Signer agreed to sign and receive the document electronically |
onFieldFilled | (data) => void | No | A field was filled |
onSigned | (data) => void | No | Signer completed signing |
onDeclined | (data) => void | No | Signer declined to sign |
onError | (data) => void | No | An error occurred |
Electronic records disclosure
By default the signer sees a line beside the Complete button saying that
completing means agreeing to sign and receive the document electronically, with a
link to the full disclosure. Completing records a consent_accepted event in
the document’s audit trail with the signer’s IP address, user agent and time,
and fires onConsentAccepted.
To use your own terms, configure the disclosure profile when your backend sends the document. Korala keeps the final-action affirmation, renders your content in its disclosure modal, and records the profile version and content hash in the audit trail.
ownConsent remains available for integrations that already present and
record consent outside Korala:
<KoralaSigner token={token} ownConsent />If you must keep this legacy mode, record the agreement from your backend so it still reaches the audit trail:
api_request "POST" "/api/v1/signing/$SIGNER_TOKEN/consent"Call it before the signer completes. Korala does not record agreement when
ownConsent is set unless your backend makes this call. A bodyless call records
external consent with the signer identity and time, without claiming which
disclosure the partner showed. Keep your own copy of those terms and the
acceptance evidence. New integrations should use a disclosure profile instead.
Custom clients that display the snapshot returned by GET /signing/:token
can send { "disclosureSha256": "<electronicDisclosure.contentSha256>" } to
the consent endpoint. Korala checks the hash before recording the snapshot
identity. A mismatch returns 409; reload the disclosure and ask the signer
to agree again.
Theme Customization
Match the signing UI to your brand by passing a theme prop. The theme replaces only accent-colored elements (buttons, field borders, focus rings); semantic colors (green success, red error, amber waiting) stay the same.
import { KoralaSigner } from '@korala/react';
import type { KoralaSignerTheme } from '@korala/react';
const theme: KoralaSignerTheme = {
accentColor: '#2563EB', // hex color
borderRadius: 8, // 0-24 px
fontFamily: 'Inter, system-ui, sans-serif', // CSS font-family
backgroundColor: '#EEF2FF', // page background (hex)
logoUrl: 'https://cdn.acme.com/logo.png', // header logo (white-label plans)
};
<KoralaSigner
token={signingToken}
theme={theme}
style={{ width: '100%', height: '100%' }}
onSigned={(data) => console.log('Signed:', data.documentId)}
/>Theme Properties
| Property | Type | Default | Description |
|---|---|---|---|
accentColor | string | #B44332 | Hex color for buttons, borders, and focus states |
borderRadius | number | 6 | Border radius in pixels (clamped 0-24) for buttons, modals, and inputs |
fontFamily | string | inherit | CSS font-family applied to the signing UI |
backgroundColor | string | platform default | Hex color for the page background |
logoUrl | string | none | HTTPS URL of a header logo. Requires a white-label plan |
All properties are optional, and each one you leave out keeps the default Korala styling: setting only borderRadius, for example, keeps the Korala accent.
Upgrading from square corners. Until September 2026, setting any theme property without borderRadius gave square corners. Unset properties now keep the defaults, so corners are 6px. For square corners, set borderRadius: 0.
White-label (paid plans). logoUrl and hideAttribution apply only when the document’s organization is on a white-label plan. On other plans Korala ignores them and keeps the “Powered by Korala” footer. Accent, radius, font, and background work on every plan. Korala resolves the plan server-side, so passing these props from a non-entitled org changes nothing.
Default theme (Korala coral accent, 6px radius):

Custom blue theme (accentColor: '#2563EB', borderRadius: 8):

KoralaTemplateEditor
Embed PDF or DOCX upload and preparation, or reopen a template your application
has uploaded. PDF preparation requires @korala/react 1.4.0 or later; DOCX
requires 1.5.0 or later. Use 1.6.0 or later for immutable version IDs and native
binding types.
Your backend authorizes the user and calls
korala.templates.createPreparationSession() with its HMAC credentials
(@korala/api-client 1.35.0 or later for version pinning). Return session.embedUrl to that user
through your own backend endpoint, then pass it to the component:
import { KoralaTemplateEditor, type KoralaTemplateEditorProps } from '@korala/react';
function PrepareTemplate({
embedUrl,
onComplete,
}: {
embedUrl: string;
onComplete: KoralaTemplateEditorProps['onComplete'];
}) {
return (
<KoralaTemplateEditor
embedUrl={embedUrl}
height={800}
onComplete={onComplete}
/>
);
}Keep API credentials on your backend. The embed URL grants access to one preparation session for one template and expires after one hour. Treat it as a credential.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
embedUrl | string | Yes | session.embedUrl from the backend session-creation response. |
onComplete | KoralaTemplateEditorProps['onComplete'] | Yes | Receives (templateId, snapshot?) after final preparation save. For PDF and DOCX, the snapshot includes the approved versionId. Existing one-argument callbacks still work. The component ignores duplicate completion events. |
onLoaded | (templateId: string) => void | No | Runs when the editor loads its template. |
height | string | number | No | Iframe height; defaults to 800 pixels. Strings accept CSS lengths, such as '80vh'. |
className | string | No | CSS class for the iframe. The iframe fills its container’s width. |
For PDF preparation, your backend chooses the mode when creating the session:
- Review: supply a non-empty list of fixed field definitions of any supported
signing type.
Users move and resize them. AI suggests positions for those fields only.
Set
signerRolesand fieldsignerRoleNamefor multiple signers; usevalueKeyto link repeated text within one signer. Setremovable: trueon repeated text definitions to let reviewers remove surplus placements; at least one occurrence and any required minimum must remain. - Full: users can add or remove fields and edit their properties. Save requires legal name, signature and date-of-signing fields for each role.
For DOCX preparation, users map text to variables, add signer fields and approve a PDF proof. Your backend can supply a variable catalog to limit their choices.
Use onComplete to close your modal or show your own confirmation. Send
templateId and snapshot.versionId to your backend, then retrieve the saved
version with korala.templates.getApprovedVersion(templateId, versionId)
(@korala/api-client 1.35.0 or later). Pass that versionId to generate or
createDocument to use the saved file, roles and fields. Approval means the user
finished template preparation; recipients sign generated documents later.
Changing embedUrl opens a fresh iframe session.
See approved PDF and DOCX versions for callback verification, version retention and generation examples.
See Embedded template preparation for server
examples, both mode screenshots, field definitions, your own upload screen,
reopening saved templates and plain iframe integration. The signing hooks and
korala: events below apply to KoralaSigner; use the preparation component’s
onLoaded and onComplete callbacks for template preparation.
KoralaSignaturePad
A canvas-based signature drawing pad. Useful for capturing signatures before uploading them via the API.
import { KoralaSignaturePad } from '@korala/react';
function CaptureSignature() {
const [signature, setSignature] = useState<string | null>(null);
return (
<KoralaSignaturePad
width={600}
height={200}
penColor="#000000"
onSignatureCreated={({ imageDataUrl }) => {
setSignature(imageDataUrl);
}}
onClear={() => setSignature(null)}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
width | number | 600 | Canvas width in pixels |
height | number | 200 | Canvas height in pixels |
penColor | string | #000000 | Stroke color |
className | string | None | CSS class for the container |
style | CSSProperties | None | Inline styles |
onSignatureCreated | (data) => void | None | Called with { imageDataUrl } (base64 PNG) when a stroke ends |
onClear | () => void | None | Called when the canvas is cleared |
The image is cropped to the signature
Since 1.3.0 imageDataUrl is the ink, cropped to what was drawn, not the
whole width x height pad. Korala stamps a signature by fitting the image
inside the field on the page and preserving its aspect ratio, so the blank
margins around a signature are scaled with it: exporting the whole pad makes
the signature print at a fraction of the space reserved for it. On a real
filing the ink covered 52% of the exported width and 45% of its height, and
the signature landed about half the size the form allowed.
So do not assume the image matches width and height. Render it with
object-fit: contain inside a box of your own rather than setting explicit
dimensions, or it will be distorted:
<img src={imageDataUrl} alt="Signature" style={{ maxWidth: '100%', objectFit: 'contain' }} />A mark too small to be a signature — a stray tap — is exported uncropped instead, because cropping a few pixels and scaling them to fill a signature field produces a blot rather than a signature.
Hooks
useKoralaEvents
Track the signing status and event history from any component. Useful when you need signing state outside the KoralaSigner component.
import { useKoralaEvents } from '@korala/react';
function SigningStatus() {
const { status, events, lastEvent } = useKoralaEvents({
signingUrl: 'https://app.korala.ai',
});
return (
<div>
<p>Status: {status}</p>
<p>Events received: {events.length}</p>
{status === 'signed' && <p>Document signed.</p>}
</div>
);
}Status Values
| Status | Description |
|---|---|
loading | Initial state, waiting for iframe |
ready | Iframe initialized |
loaded | Document loaded in viewer |
viewed | Signer has viewed the document |
signing | The signer is filling a field |
signed | Signing completed |
declined | Signer declined |
error | An error occurred |
useKoralaSignerRef
Control the signing iframe from your own UI.
import { KoralaSigner, useKoralaSignerRef } from '@korala/react';
function ControlledSigner({ token }: { token: string }) {
const { ref, close, getStatus, gotoField, gotoPage, getFields } =
useKoralaSignerRef();
return (
<div>
<KoralaSigner ref={ref} token={token} />
<button onClick={close}>Close signing</button>
<button onClick={getStatus}>Check status</button>
<button onClick={() => gotoField()}>Go to next field</button>
</div>
);
}| Method | Returns | What it does |
|---|---|---|
close() | None | Closes the signing session |
getStatus() | KoralaGetStatusResult | Document status and progress counts |
gotoField(fieldId?) | KoralaGotoFieldResult | Scrolls to a field; omit the id for the next unfilled one |
gotoPage(pageNumber) | KoralaGotoPageResult | Scrolls to a 1-based page |
getFields() | KoralaGetFieldsResult | Every field: type, label, page, required, filled |
Korala’s own navigation controls
Embeds hide the signing viewer’s page and previous/next-field controls by
default. Set showNavigation to render them instead of building controls in
your host app:
<KoralaSigner ref={ref} token={token} showNavigation />Direct signing links show the controls.
Checking what the viewer supports
The SDK and the signing app ship separately, so your app can be on a newer SDK
than the page it embeds. The ready event lists the commands the viewer
understands; older signing apps omit it and support only close and
get_status. Calling an unsupported command leaves its promise pending until
the SDK’s timeout, so feature-detect rather than assume:
const [commands, setCommands] = useState<string[]>([]);
<KoralaSigner
ref={ref}
token={token}
onReady={(data) => setCommands(data.commands ?? [])}
/>;
{commands.includes('goto_field') && (
<button onClick={() => gotoField()}>Go to next field</button>
)}Building your own field navigation
Long agreements usually put the signature block in the last third of the
document. getFields and gotoField together let you put next/previous
controls, a field list, or a progress rail in your own chrome.
function FieldNavigator({ token }: { token: string }) {
const { ref, gotoField, getFields } = useKoralaSignerRef();
const [fields, setFields] = useState<KoralaFieldSummary[]>([]);
// The list is available once the document has loaded.
const load = async () => setFields((await getFields())?.fields ?? []);
return (
<div>
<KoralaSigner ref={ref} token={token} onLoaded={load} onFieldFilled={load} />
<ol>
{fields.map((field) => (
<li key={field.fieldId}>
<button onClick={() => gotoField(field.fieldId)}>
{field.label ?? field.fieldType}: page {field.pageNumber}
{field.filled ? ' ✓' : ''}
</button>
</li>
))}
</ol>
</div>
);
}gotoField resolves once the viewer has stopped scrolling:
interface KoralaGotoFieldResult {
fieldId: string | null;
pageNumber: number | null;
outcome: 'arrived' | 'unreachable' | 'not_found';
}arrived means the field is on screen. not_found means no field matched the
id. unreachable means the viewer could not bring it into view. Surface that
outcome instead of treating it as success.
interface KoralaFieldSummary {
fieldId: string;
fieldType: string;
label: string | null;
pageNumber: number; // 1-based
required: boolean;
filled: boolean;
shared: boolean; // another signer's field that you may fill
}
interface KoralaGetFieldsResult {
fields: KoralaFieldSummary[];
nextFieldId: string | null; // what the signer's own button targets next
}Events
The signing iframe sends events to the parent window via postMessage, each prefixed with korala:.
Event Payloads
Ready
interface ReadyEventData {
version: string; // Embed protocol version
commands?: string[]; // Commands this viewer understands
}Consent Accepted
interface ConsentAcceptedEventData {
documentId: string;
signerId: string;
consentedAt: string; // ISO timestamp
}Does not fire when ownConsent is set.
Document Loaded
interface LoadedEventData {
documentId: string;
documentName: string;
signerName: string;
signerEmail: string;
totalFields: number;
requiredFields: number;
}Document Viewed
interface ViewedEventData {
documentId: string;
signerId: string;
viewedAt: string; // ISO 8601
}Field Filled
interface FieldFilledEventData {
fieldId: string;
fieldType: string; // 'signature' | 'initials' | 'date' | 'text' | 'checkbox'
pageNumber?: number; // 1-based page; absent on signing apps older than the SDK
filledFields: number;
totalRequiredFields: number; // live; see below
}totalRequiredFields is a live denominator, not a constant. Choose-one
groups count as one unit, and a
conditionally required field
joins or leaves the count as the signer toggles its controller. Checking a
W-9’s LLC box raises it by one, unchecking lowers it. Render progress from
the latest event rather than caching the number from document.loaded.
Document Signed
interface SignedEventData {
documentId: string;
signerId: string;
signedAt: string; // ISO 8601
redirectUrl?: string;
}Document Declined
interface DeclinedEventData {
documentId: string;
signerId: string;
reason?: string;
declinedAt: string; // ISO 8601
}Error
interface ErrorEventData {
code: string;
message: string;
recoverable: boolean;
}Type Guard
Use isKoralaEvent to filter Korala events from other postMessage traffic:
import { isKoralaEvent } from '@korala/react';
window.addEventListener('message', (event) => {
if (isKoralaEvent(event)) {
console.log('Korala event:', event.data.type);
}
});Full Example
For a working Next.js app with embedded signing, template preparation, sequential signers, and signing packets, see the example repository on GitHub . It uses the published SDKs and generates synthetic PDFs, so you do not need to create a template first.
The preparation example covers Review and Full modes, upload inside Korala or in your own application, reopening a saved template, and signing a document created from it. Review fields are defined by the backend, including linked text values and permission to remove repeated placements.
A minimal signing flow: create a document via the API, then embed the signing experience.
import { useState } from 'react';
import { KoralaSigner } from '@korala/react';
export function SigningFlow() {
const [status, setStatus] = useState<'form' | 'signing' | 'complete'>('form');
const [token, setToken] = useState<string | null>(null);
async function startSigning(name: string, email: string) {
// Call your backend to create a document and get a signing token
const res = await fetch('/api/create-signing-session', {
method: 'POST',
body: JSON.stringify({ name, email }),
});
const { signingToken } = await res.json();
setToken(signingToken);
setStatus('signing');
}
if (status === 'signing' && token) {
return (
<div style={{ height: '100vh' }}>
<KoralaSigner
token={token}
signingUrl={process.env.NEXT_PUBLIC_KORALA_URL}
style={{ width: '100%', height: '100%', border: 'none' }}
onSigned={() => setStatus('complete')}
onDeclined={(data) => {
alert(`Signing declined: ${data.reason}`);
setStatus('form');
}}
onError={(data) => {
console.error('Error:', data.message);
if (!data.recoverable) setStatus('form');
}}
/>
</div>
);
}
if (status === 'complete') {
return <p>Document signed.</p>;
}
return (
<form onSubmit={(e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startSigning(fd.get('name') as string, fd.get('email') as string);
}}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Start signing</button>
</form>
);
}