npm package: @kopra-dev/sdk
Description: Kopra JavaScript/TypeScript SDK - embed custom field editors and tenant configuration
Latest version: 0.2.0

# @kopra-dev/sdk

Embed custom field editors and tenant configuration into any web app.

## Install

```bash
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:

```html
<script src="dist/index.global.js"></script>
<script>
  const { KopraSDK } = Kopra;
  const sdk = new KopraSDK({ /* ... */ });
</script>
```

## Quick Start

```ts
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,
});
```

```html
<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

```ts
// 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:

```json
{
  "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:

```ts
interface TokenResponse {
  token: string;
  fieldEditorUrl: string;
  tenantConfigUrl: string;
  fieldsGroupId?: string;
  expiresAt?: string;
}
```

### Python / Flask

```python
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

```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](https://github.com/KairoKoik/Kopra/tree/main/app/examples/python) | [Go](https://github.com/KairoKoik/Kopra/tree/main/app/examples/go) | [PHP](https://github.com/KairoKoik/Kopra/tree/main/app/examples/html)

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](#autosave)). |
| `debug` | `boolean` | No | Log SDK internals to the console. Default: `false`. |

The `TokenRequest` passed to your `getToken` callback:

```ts
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.

```ts
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.

```ts
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`.

```ts
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.

```ts
const values = await sdk.getFieldValues();
// { company_size: '51-200', industry: 'Healthcare' }
```

### `validateFields()`

Validate the current field values without saving.

```ts
const { valid, errors } = await sdk.validateFields();
if (!valid) console.log('Validation errors:', errors);
```

### `enableAutosave(options?)`

Enable autosave at runtime. See [Autosave](#autosave).

### `disableAutosave()`

Disable autosave.

### `updateConfig(newConfig)`

Update SDK configuration. Destroys existing embeds - call `loadCustomFields` / `loadFieldConfiguration` again after.

```ts
sdk.updateConfig({ currentTenant: 'new-tenant-456' });
```

### `destroy()`

Clean up all iframes, event listeners, and timers.

```ts
sdk.destroy();
```

### `getDefaultTheme()`

Returns the built-in default `ThemeConfig` object (frozen/immutable). Useful as a starting point for customization.

```ts
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. |

```ts
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:

```ts
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.

```ts
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:

```ts
const result = await sdk.saveFields();
// { success: true, values: { ... } }
```

### Autosave Configuration

Autosave can be customized or disabled:

```ts
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:

```json
{
  "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](#configuration) and [Quick Start](#quick-start) above.

### React

```tsx
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

```vue
<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.

```tsx
'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

```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

- [Interactive API docs](/api/docs) - all 26 REST endpoints
- [OpenAPI spec](/api/docs.json) - import into Postman or other tools
- [Postman collection](https://github.com/KairoKoik/Kopra/blob/main/app/examples/Kopra.postman_collection.json) - 22 pre-configured requests
- [LLM integration prompt](/api/llm-context) - give this to your AI assistant
- Backend examples: [Node.js](#express--nodejs) | [Python](#python--flask) | [Go](#go) | [PHP](https://github.com/KairoKoik/Kopra/tree/main/app/examples/html)