JSPM

@kopra-dev/sdk

0.2.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 256
  • Score
    100M100P100Q60187F
  • License MIT

Kopra JavaScript/TypeScript SDK - embed custom field editors and tenant configuration

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/sdk

For 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, unwrap data first: 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_URL env var points to a running Kopra instance.

Token request returns 401

  • Your API key is missing or invalid. Pass it via the X-API-Key header 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 onFieldsError is 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