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 KopraSDK as a global (also available as Kopra.KopraSDK):
<script src="dist/index.global.js"></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 for end users to fill in values
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.
Autosave
Autosave debounces saves after field value changes. Configure at initialization or enable at runtime.
// At initialization
const sdk = new KopraSDK({
currentTenant: 'northstar-staffing',
getToken: myGetToken,
autosave: {
enabled: true,
debounceMs: 2000, // default: 2000
validateBeforeSave: true, // default: true
onAutosaveStart: () => showSpinner(),
onAutosaveSuccess: (values) => hideSpinner(),
onAutosaveError: (err) => showToast(err),
},
});
// Or enable at runtime
sdk.enableAutosave({ debounceMs: 1500 });
// Disable
sdk.disableAutosave();AutosaveConfig
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
false |
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