Package Exports
- @kopra-dev/sdk
Readme
@kopra-dev/sdk
Embed custom field editors and tenant configuration into any web app.
Install
npm install @kopra-dev/sdk
# or
yarn add @kopra-dev/sdk
# or
pnpm add @kopra-dev/sdkFor use without a build step, include the IIFE bundle via a <script> tag. This exposes Kopra as a global namespace:
<script src="dist/index.global.js"></script>
<script>
const { KopraSDK } = Kopra;
const sdk = new KopraSDK({ /* ... */ });
</script>Quick Start
import { KopraSDK } from '@kopra-dev/sdk';
const sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
onFieldsSaved: (values) => console.log('Saved:', values),
onFieldsError: (err) => console.error('Error:', err),
});
// Load the field editor - values autosave after 2s of inactivity
await sdk.loadCustomFields('editor-container', {
fieldGroupKey: 'customer',
entityId: 'contact-123',
});
// Load the tenant config panel so customers can manage their own fields
await sdk.loadFieldConfiguration('config-container', {
fieldGroupKey: 'customer',
fieldLimit: 10,
});<div id="editor-container"></div>
<div id="config-container"></div>Backend Setup (Required)
Your API key must never reach the browser. Create a backend endpoint that proxies token requests to Kopra. The SDK calls your endpoint via the getToken callback.
Express / Node.js
// server.ts
import express from 'express';
const app = express();
app.use(express.json());
app.post('/api/kopra-token', async (req, res) => {
const response = await fetch(
`${process.env.KOPRA_URL}/api/auth/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.KOPRA_API_KEY!,
},
body: JSON.stringify({
tenantId: req.body.tenantId,
fieldGroupKey: req.body.fieldGroupKey,
fieldLimit: req.body.fieldLimit,
}),
},
);
if (!response.ok) {
return res.status(response.status).json({ error: 'Token request failed' });
}
const json = await response.json();
// Kopra wraps the payload in { success, data }
res.json(json.data);
});
app.listen(3000);The response from POST /api/auth/token looks like:
{
"success": true,
"data": {
"token": "eyJ...",
"fieldEditorUrl": "https://your-kopra.com/embed/field-editor",
"tenantConfigUrl": "https://your-kopra.com/embed/tenant-config",
"fieldsGroupId": "uuid-of-resolved-field-group",
"expiresAt": "2026-03-28T12:00:00.000Z"
}
}Your backend returns json.data directly to the SDK. The SDK expects the TokenResponse shape:
interface TokenResponse {
token: string;
fieldEditorUrl: string;
tenantConfigUrl: string;
fieldsGroupId?: string;
expiresAt?: string;
}Python / Flask
from flask import Flask, jsonify, request
import requests, os
app = Flask(__name__)
KOPRA_URL = os.environ['KOPRA_URL']
KOPRA_API_KEY = os.environ['KOPRA_API_KEY']
@app.post('/api/kopra-token')
def kopra_token():
resp = requests.post(
f'{KOPRA_URL}/api/auth/token',
json=request.get_json() or {},
headers={'Content-Type': 'application/json', 'X-API-Key': KOPRA_API_KEY},
timeout=10,
)
if not resp.ok:
return jsonify({'error': 'Token request failed'}), resp.status_code
return jsonify(resp.json().get('data', {}))Go
http.HandleFunc("/api/kopra-token", func(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest("POST", os.Getenv("KOPRA_URL")+"/api/auth/token", r.Body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("KOPRA_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, `{"error":"Could not reach Kopra"}`, http.StatusBadGateway)
return
}
defer resp.Body.Close()
var result struct {
Data json.RawMessage `json:"data"`
}
json.NewDecoder(resp.Body).Decode(&result)
w.Header().Set("Content-Type", "application/json")
w.Write(result.Data)
})Full runnable examples: Python | Go | PHP
See /api/docs on your Kopra instance for the full API reference.
Configuration
All options for KopraSDKConfig:
| Option | Type | Required | Description |
|---|---|---|---|
currentTenant |
string |
Yes | Tenant ID that scopes all operations. |
getToken |
(req: TokenRequest) => Promise<TokenResponse> |
Recommended | Async callback that calls your backend to get a Kopra token. |
apiKey |
string |
Dev only | Calls Kopra's token endpoint directly from the browser. Logs a warning. Never ship to production. |
backendUrl |
string |
Only with apiKey |
Base URL of the Kopra server. Not needed when using getToken. |
endpoints |
{ token?: string } |
No | Override default API paths. Default token path: /api/auth/token. |
onFieldsSaved |
(values: Record<string, unknown>) => void |
No | Called after field values are saved via the editor. |
onFieldsError |
(error: unknown) => void |
No | Called on field editor errors. |
onConfigSaved |
(data: unknown) => void |
No | Called after tenant config changes are saved. |
onConfigError |
(error: unknown) => void |
No | Called on config panel errors. |
autosave |
AutosaveConfig |
No | Autosave configuration (see Autosave). |
debug |
boolean |
No | Log SDK internals to the console. Default: false. |
The TokenRequest passed to your getToken callback:
interface TokenRequest {
tenantId: string;
fieldGroupKey?: string;
fieldLimit?: number;
}Methods
loadCustomFields(containerId, options)
Embeds the field-value editor into the specified container. End users fill in field values here.
await sdk.loadCustomFields('my-container', {
fieldGroupKey: 'customer', // which field group to load
entityId: 'contact-123', // the entity these values belong to
initialHeight: '300px', // optional, default '100px'
theme: { /* ThemeConfig */ },// optional
});loadFieldConfiguration(containerId, options)
Embeds the tenant field configuration panel. Tenants can add, edit, and delete their own custom fields.
await sdk.loadFieldConfiguration('config-container', {
fieldGroupKey: 'customer',
fieldLimit: 10, // optional, max fields the tenant can create
height: '600px', // optional, default '600px'
});saveFields(options?)
Programmatically trigger a save of the current field values. Returns a SaveFieldsResult.
const result = await sdk.saveFields({
timeout: 10000, // optional, ms to wait (default 10000)
dryRun: false, // optional, validate without saving
validateBeforeSave: true, // optional, run validation first
});
if (result.success) {
console.log('Values:', result.values);
} else {
console.log('Errors:', result.errors);
}getFieldValues()
Get the current field values from the editor without saving.
const values = await sdk.getFieldValues();
// { company_size: '51-200', industry: 'Healthcare' }validateFields()
Validate the current field values without saving.
const { valid, errors } = await sdk.validateFields();
if (!valid) console.log('Validation errors:', errors);enableAutosave(options?)
Enable autosave at runtime. See Autosave.
disableAutosave()
Disable autosave.
updateConfig(newConfig)
Update SDK configuration. Destroys existing embeds - call loadCustomFields / loadFieldConfiguration again after.
sdk.updateConfig({ currentTenant: 'new-tenant-456' });destroy()
Clean up all iframes, event listeners, and timers.
sdk.destroy();getDefaultTheme()
Returns the built-in default ThemeConfig object (frozen/immutable). Useful as a starting point for customization.
const base = sdk.getDefaultTheme();Events
The SDK communicates with embedded iframes via PostMessage. You can listen for these events on window:
Field Editor Events
| Event | Description |
|---|---|
kopra-field-values-saved |
Field values were saved successfully. |
kopra-field-values-error |
An error occurred while saving. |
kopra-field-value-changed |
A single field value changed (triggers autosave). |
kopra-resize |
The editor iframe resized. |
Tenant Config Events
| Event | Description |
|---|---|
kopra-tenant-field-saved |
A tenant field was created. |
kopra-tenant-field-updated |
A tenant field was updated. |
kopra-tenant-field-deleted |
A tenant field was deleted. |
kopra-tenant-field-error |
An error occurred in the config panel. |
kopra-config-resize |
The config iframe resized. |
window.addEventListener('message', (e) => {
if (e.data.type === 'kopra-tenant-field-saved') {
console.log('New field:', e.data.fieldData);
}
});Event constants are also exported for type safety:
import { PostMessageEvents } from '@kopra-dev/sdk';
// PostMessageEvents.TENANT_FIELD_SAVED === 'kopra-tenant-field-saved'Theming
Pass a theme object to loadCustomFields to match your app's design. Every element supports both inline styles and CSS class names.
await sdk.loadCustomFields('container', {
fieldGroupKey: 'customer',
entityId: 'contact-123',
theme: {
container: { display: 'flex', flexDirection: 'column', gap: '16px' },
label: { fontSize: '14px', fontWeight: '600', color: '#1e293b' },
input: {
width: '100%',
padding: '10px 12px',
border: '1px solid #cbd5e1',
borderRadius: '8px',
},
inputFocus: {
outline: 'none',
borderColor: '#3b82f6',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)',
},
select: { /* same pattern as input */ },
textarea: { /* same pattern as input */ },
fieldGlobal: { borderLeft: '3px solid #3b82f6', paddingLeft: '10px' },
fieldTenant: { borderLeft: '3px solid #10b981', paddingLeft: '10px' },
saveButton: { display: 'none' }, // hide to use programmatic save
},
});ThemeConfig keys
Each key accepts a CSSStyles object (camelCase CSS properties). Keys ending in Class accept a CSS class name string instead.
| Key | Class variant | Description |
|---|---|---|
container |
containerClass |
Outer wrapper around all fields. |
fieldContainer |
fieldContainerClass |
Wrapper for each field (label + input). |
fieldContainerSingle |
fieldContainerSingleClass |
Fields that span the full width (textarea, json). |
fieldGlobal |
fieldGlobalClass |
Fields defined globally by the SaaS owner. |
fieldTenant |
fieldTenantClass |
Fields defined by the tenant. |
label |
labelClass |
Field labels. |
input |
inputClass |
Text/number/date/email/url inputs. |
inputFocus |
- | Styles applied on input focus. |
select |
selectClass |
Select dropdowns. |
textarea |
textareaClass |
Textarea fields. |
saveButton |
saveButtonClass |
The save button (set display: 'none' to hide). |
Both fooClass and foo (inline styles) can be set together. Inline styles take precedence on conflict.
Saving
Autosave is enabled by default. Field values save automatically after 2 seconds of inactivity. The embedded editor shows a "Saving..." / "Changes saved" status indicator.
You can also save programmatically at any time:
const result = await sdk.saveFields();
// { success: true, values: { ... } }Autosave Configuration
Autosave can be customized or disabled:
const sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: myGetToken,
autosave: {
enabled: true, // default: true
debounceMs: 2000, // default: 2000
validateBeforeSave: true, // default: true
onAutosaveStart: () => showSpinner(),
onAutosaveSuccess: (values) => hideSpinner(),
onAutosaveError: (err) => showToast(err),
},
});
// Disable autosave to use manual saves only
sdk.disableAutosave();
// Re-enable with custom settings
sdk.enableAutosave({ debounceMs: 1500 });AutosaveConfig
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Whether autosave is active. |
debounceMs |
number |
2000 |
Milliseconds to wait after the last change before saving. |
validateBeforeSave |
boolean |
true |
Run validation before autosaving. Skips save on errors. |
onAutosaveStart |
() => void |
noop | Called when an autosave begins. |
onAutosaveSuccess |
(values) => void |
noop | Called after a successful autosave. |
onAutosaveError |
(error: string) => void |
noop | Called when autosave fails. |
Field Types
Kopra supports 12 field types:
| Type | Input | Description |
|---|---|---|
string |
Text input | Short text. |
text |
Text input | Alias for string. |
number |
Number input | Numeric value. |
boolean |
Checkbox | True/false. |
date |
Date picker | ISO date string. |
email |
Email input | Validated email address. |
url |
URL input | Validated URL. |
select |
Dropdown | Single selection from predefined options. |
multiselect |
Multi-select | Multiple selections from predefined options. |
enum |
Dropdown | Alias for select. |
textarea |
Textarea | Multi-line text. |
json |
Textarea | Freeform JSON. |
Fields are configured with a schema object when creating global or tenant fields via the API:
{
"type": "select",
"validation": {
"required": true,
"options": ["Option A", "Option B"]
},
"uiHints": {
"placeholder": "Select...",
"helpText": "Choose the best match"
}
}Framework Examples
Each example below wraps the SDK in a framework-specific lifecycle. For full configuration options, see Configuration and Quick Start above.
React
import { useEffect, useRef } from 'react';
import { KopraSDK } from '@kopra-dev/sdk';
function useKopra(containerId: string, fieldGroupKey: string, entityId: string) {
const sdkRef = useRef<KopraSDK | null>(null);
useEffect(() => {
const sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
});
sdkRef.current = sdk;
sdk.loadCustomFields(containerId, { fieldGroupKey, entityId });
return () => sdk.destroy();
}, [containerId, fieldGroupKey, entityId]);
return sdkRef;
}
// Usage: <div id="editor" /> + useKopra('editor', 'customer', 'contact-123')Vue 3
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { KopraSDK } from '@kopra-dev/sdk';
const container = ref<HTMLElement | null>(null);
let sdk: KopraSDK | null = null;
onMounted(() => {
sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
});
sdk.loadCustomFields('kopra-editor', {
fieldGroupKey: 'customer',
entityId: 'contact-123',
});
});
onUnmounted(() => sdk?.destroy());
</script>
<template>
<div id="kopra-editor" ref="container" />
</template>Next.js
The SDK requires the DOM, so it must only run on the client. Use useEffect to avoid SSR issues.
'use client';
import { useEffect, useRef } from 'react';
import type { KopraSDK as KopraSDKType } from '@kopra-dev/sdk';
export default function KopraEditor() {
const sdkRef = useRef<KopraSDKType | null>(null);
useEffect(() => {
let sdk: KopraSDKType;
import('@kopra-dev/sdk').then(({ KopraSDK }) => {
sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
});
sdkRef.current = sdk;
sdk.loadCustomFields('kopra-editor', {
fieldGroupKey: 'customer',
entityId: 'contact-123',
});
});
return () => sdkRef.current?.destroy();
}, []);
return <div id="kopra-editor" />;
}Svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { KopraSDK } from '@kopra-dev/sdk';
let container: HTMLElement;
let sdk: KopraSDK;
onMount(() => {
sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: async (req) => {
const res = await fetch('/api/kopra-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
return res.json();
},
});
sdk.loadCustomFields('kopra-editor', {
fieldGroupKey: 'customer',
entityId: 'contact-123',
});
});
onDestroy(() => sdk?.destroy());
</script>
<div id="kopra-editor" bind:this={container} />Troubleshooting
Iframe doesn't load / blank white box
- Check the browser console for CORS errors. Your Kopra instance must allow the origin of your app.
- Verify the token endpoint returns
{ token, fieldEditorUrl, tenantConfigUrl }. If your backend returns the raw Kopra response, unwrapdatafirst:res.json(json.data).
"Authentication timeout" after 30 seconds
- The SDK sends the token to the iframe via postMessage. If the iframe URL is wrong or unreachable, it can't receive the token.
- Check that your
KOPRA_URLenv var points to a running Kopra instance.
Token request returns 401
- Your API key is missing or invalid. Pass it via the
X-API-Keyheader from your backend. - API keys are shown only once when created. Generate a new one from the dashboard if lost.
Token request returns 429
- You've exceeded 50 token requests per 15 minutes. This limit applies per API key.
- Cache tokens on your backend. Tokens are valid for 1 hour by default.
Field values don't save
- Check that
onFieldsErroris set. Silent failures are logged there. - Verify the field group key matches a field group you created in the dashboard.
- If using autosave, it debounces by 2 seconds. Changes save automatically after the user stops typing.
Fields appear but are empty
- Field values are scoped to (tenantId, fieldGroupKey, entityId). If any of these change, you get a fresh empty form.
- Use
getFieldValues()to check what's stored for a given combination.
Resources
- Interactive API docs - all 26 REST endpoints
- OpenAPI spec - import into Postman or other tools
- Postman collection - 22 pre-configured requests
- LLM integration prompt - give this to your AI assistant
- Backend examples: Node.js | Python | Go | PHP