# React builders

Embed email and segment builders in your React product using the preview package for partner accounts.

`@kirora/react` puts Kirora's email builder and segment builder inside your own React app, so your customers can create email designs and define audiences inside your product. It is for partner accounts, which run one Kirora workspace per tenant, and is in preview.

## How it works

The builders never see your API key.

1. **Your server holds a partner key.** A partner key is server-only: a request that carries an `Origin` header is refused, so it cannot be used from a browser.
2. **Your server mints an embed session.** When a page needs a builder, your server calls [`POST /v1/embed-sessions`](/docs/api/reference/createEmbedSession) with the partner key and a `Kirora-Workspace` header naming the tenant's `externalId`. Kirora returns a session token (`es_…`) that lasts 15 minutes.
3. **The browser only holds the session.** `KiroraProvider` calls your `fetchSession` function to get one, and calls it again a minute before it expires, or once more if the API answers 401.
4. **Sessions are read-only, apart from image upload.** The builders read what they need, render previews, count audiences and upload images. They never create, edit, delete, publish or send. They hand the design to your code, and your server saves it with the partner key.

Each session works only from the origins listed on the partner key, and only on the routes its components need. Revoking the partner key ends every session it minted.

## Install

`@kirora/react` is not yet published to npm. When it is, install it next to React 19.2 or later, its peer dependencies:

```bash
npm install @kirora/react react@^19.2 react-dom@^19.2
```

The package ships an ES module, its type definitions, and one stylesheet, `@kirora/react/kirora.css`, which you import once.

## Before you start

- **A partner account.** Partner status is enabled by Kirora for your organization; it is not self-serve. Create each tenant's workspace with [`POST /v1/workspaces`](/docs/api/reference/createWorkspace), giving it your own tenant id as `externalId`.
- **A partner key with the `workspaces:manage` scope.** In the dashboard, open **Developer → API keys** and create a key of the **Partner** kind.
- **Allowed origins on that key.** Add every website the builders will run on, as exact origins (scheme and host, such as `https://app.example.com`; no paths or wildcards; up to 20). Include your local development origin, such as `http://localhost:5173`. Minting a session with a key that has no allowed origins is refused with 409 `EMBED_ORIGIN_NOT_ALLOWED`.

## Mint a session on your server

Authenticate your own user first, decide which tenant they belong to, then mint a session for that tenant and return only the session to the page. This example uses Express:

```ts
// server.ts (Node.js 18+, Express)
import express from 'express';

const app = express();

app.post('/api/kirora-session', async (req, res) => {
  const tenantId = await tenantForRequest(req); // your auth: which tenant is this user in?

  const response = await fetch('https://api.kirora.io/v1/embed-sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.KIRORA_API_KEY}`, // the partner key
      'Kirora-Workspace': tenantId,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      components: ['email-builder', 'segment-builder'],
    }),
  });

  const body = await response.json();
  if (!response.ok) {
    return res.status(response.status).json(body);
  }
  // { token: "es_…", expiresAt: "…", apiUrl: "https://api.kirora.io" }
  res.json(body.data);
});
```

The request body takes:

| Field | Type | |
| --- | --- | --- |
| `components` | `("email-builder" \| "segment-builder")[]` | Required. Which builders the session may serve. |
| `includeAttributeValues` | `boolean` | Default `false`. When `true`, the segment builder can suggest existing attribute values and event property samples. These are customer data, so turn it on only for users who may see them. |

The 201 response is `{ "data": { "token", "expiresAt", "apiUrl" } }`. Return `data` as it is: the provider sends requests to its `apiUrl`.

## Render the builders

Wrap the part of your app that shows a builder in `KiroraProvider`, and import the stylesheet once:

```tsx
import {
  EmailBuilder,
  KiroraProvider,
  SegmentBuilder,
  type EmailBuilderHandle,
  type EmailDesign,
  type SegmentRules,
} from '@kirora/react';
import '@kirora/react/kirora.css';
import { useRef, useState } from 'react';

async function fetchSession() {
  const response = await fetch('/api/kirora-session', { method: 'POST' });
  if (!response.ok) throw new Error('Could not start a Kirora session');
  return response.json();
}

export function Designer() {
  return (
    <KiroraProvider
      fetchSession={fetchSession}
      appearance={{
        variables: {
          colorPrimary: '#2f6b4f',
          fontFamily: 'Inter, system-ui, sans-serif',
          borderRadius: '10px',
        },
      }}
    >
      <EmailTab />
      <SegmentTab />
    </KiroraProvider>
  );
}

function EmailTab() {
  const builder = useRef<EmailBuilderHandle>(null);
  const [design, setDesign] = useState<EmailDesign>();
  const [problem, setProblem] = useState<string | null>(null);
  const [errors, setErrors] = useState<unknown>();

  async function save() {
    const response = await fetch('/api/templates', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Weekly menu', ...design }),
    });
    // A refusal goes back to the builder, which marks the blocks concerned.
    setErrors(response.ok ? undefined : await response.json());
  }

  async function preview() {
    const { html, plainText } = await builder.current!.exportHtml();
    console.log(html.length, plainText.length);
  }

  return (
    <>
      <button disabled={!design || Boolean(problem)} onClick={save}>
        Save template
      </button>
      <button onClick={preview}>Export HTML</button>
      {problem ? <p>{problem}</p> : null}
      <div style={{ height: 720 }}>
        <EmailBuilder
          ref={builder}
          onReady={(status) => setProblem(status.problem)}
          onChange={(next, status) => {
            setDesign(next);
            setProblem(status.problem);
          }}
          errors={errors}
        />
      </div>
    </>
  );
}

function SegmentTab() {
  const [rules, setRules] = useState<SegmentRules>();
  const [problem, setProblem] = useState<string | null>(null);
  const [errors, setErrors] = useState<unknown>();

  async function save() {
    const response = await fetch('/api/segments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Toronto subscribers', rules }),
    });
    setErrors(response.ok ? undefined : await response.json());
  }

  return (
    <>
      <button disabled={!rules || Boolean(problem)} onClick={save}>
        Save segment
      </button>
      {problem ? <p>{problem}</p> : null}
      <SegmentBuilder
        onReady={(status) => setProblem(status.problem)}
        onChange={(next, status) => {
          setRules(next);
          setProblem(status.problem);
        }}
        errors={errors}
      />
    </>
  );
}
```

Each builder draws inside its own shadow root, so your page's CSS cannot restyle it and its CSS cannot leak into your page. The email builder fills the height of its container (at least 640px) and scrolls inside it.

## KiroraProvider

| Prop | Type | |
| --- | --- | --- |
| `fetchSession` | `() => Promise<EmbedSession>` | Required. Asks your server for a session. Called on first use, shortly before each session expires, and once more after a 401. A rejected call is retried on the next request. Read once, when the provider mounts. |
| `apiUrl` | `string` | Optional. The API address to use if a session has no `apiUrl`; a session's own `apiUrl` wins. Read once. |
| `appearance` | `Appearance` | Optional. Colours, font and corners to match your app. |
| `children` | `ReactNode` | The part of your app that shows builders. |

`EmbedSession` is `{ token: string; expiresAt: string; apiUrl?: string }`, which is the `data` of the embed-session response. Unmounting the provider cancels any request it still has in flight.

### Appearance

`appearance` takes `{ variables: { … } }`. Anything you leave out keeps Kirora's look; secondary text, dividers and tinted surfaces are derived from the colours you set.

| Variable | Sets |
| --- | --- |
| `colorPrimary` | Buttons, links, the selection and focus ring. |
| `colorBackground` | The page and cards; wells and bars are tinted from it. |
| `colorText` | Text; muted text is mixed from it. |
| `colorDanger` | Errors and refusals. |
| `fontFamily` | The builders' typeface. |
| `borderRadius` | Controls' corners; smaller and larger corners scale from it. |

## EmailBuilder

The email builder is uncontrolled: you give it a design to open and hear every change.

| Prop | Type | |
| --- | --- | --- |
| `defaultValue` | `EmailDesign` | The design to open, read once on mount. Without one, the builder starts from its starter layout. Its `kind` (`"campaign"`, `"journey"` or `"transactional"`) says what the email is for. |
| `onChange` | `(design: EmailDesign, status: BuilderStatus) => void` | Every change, as the design the API takes. Called once edits pause (300 ms), and on unmount with any change still pending. |
| `onReady` | `(status: BuilderStatus) => void` | Called once, when the builder has opened its design. |
| `errors` | `unknown` | Your last save's refusal: the API's error body. The builder marks the blocks it names. |
| `ref` | `Ref<EmailBuilderHandle>` | Methods for reading and loading designs. |

`EmailBuilderHandle` methods:

| Method | Returns | |
| --- | --- | --- |
| `getDesign()` | `EmailDesign` | The design as it stands. |
| `exportHtml()` | `Promise<{ design, html, plainText }>` | The design with the HTML and plain text a send would use, rendered through the session. |
| `loadDesign(design)` | `void` | Replaces the design and starts a fresh undo history. Throws if the builder cannot open the design exactly. |
| `getProblem()` | `string \| null` | What keeps the design from rendering (a missing footer address, say), or `null`. |

`EmailDesign` holds `blocks` and `theme`, plus optional `kind`, `schemaVersion`, `subject` and `plainTextOverride`. Spread it into the body of [`createTemplate`](/docs/api/reference/createTemplate) or [`updateTemplate`](/docs/api/reference/updateTemplate) with the template's `name`.

## SegmentBuilder

The segment builder can be controlled or uncontrolled. The live audience count comes through the session.

| Prop | Type | |
| --- | --- | --- |
| `value` | `SegmentRules` | The rules, when you hold them (controlled). Pass the rules from `onChange` back in. |
| `defaultValue` | `SegmentRules` | The rules to open when the builder holds them (uncontrolled), read once. New rules start with one empty condition. Pass this or `value`, not both. |
| `onChange` | `(rules: SegmentRules, status: BuilderStatus) => void` | Every change, as the rules the API takes. |
| `onReady` | `(status: BuilderStatus) => void` | Called once, when the builder has opened. |
| `errors` | `unknown` | Your last save's refusal: the API's error body. The builder marks the conditions it names. |

`SegmentRules` is `{ all: [...] }` or `{ any: [...] }`, the same shape [`createSegment`](/docs/api/reference/createSegment) and [`updateSegment`](/docs/api/reference/updateSegment) take as `rules`.

## BuilderStatus

Both builders report `BuilderStatus`, `{ problem: string | null }`, with `onReady` and every `onChange`. `problem` is the builder's own sentence for what still keeps the design or rules from saving, or `null` when they are ready. Hold your save button while there is a problem.

The package also exports the types `EmailBlock`, `EmailTheme` and `SegmentCondition`.

## Save designs through your server

The builders cannot save anything; a session is read-only apart from image upload. Your page sends the design to your own server, and your server saves it with the partner key and the tenant's `Kirora-Workspace` header:

```ts
app.post('/api/templates', express.json(), async (req, res) => {
  const tenantId = await tenantForRequest(req);
  const response = await fetch('https://api.kirora.io/v1/templates', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.KIRORA_API_KEY}`,
      'Kirora-Workspace': tenantId,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(req.body), // { name, ...design }
  });
  // Relay the status and body, so a refusal reaches the builder as `errors`.
  res.status(response.status).type('application/json').send(await response.text());
});
```

Segments are the same, with `POST /v1/segments` and `{ name, rules }`. Use `PATCH` on `/v1/templates/{id}` and `/v1/segments/{id}` to update. Because your server makes the call, it also decides what a user may save, publish or send.

## Errors

| What happens | Why |
| --- | --- |
| Minting returns 409 `EMBED_ORIGIN_NOT_ALLOWED` | The partner key has no allowed origins. Add them to the key. |
| The builder's requests fail with 403 `EMBED_ORIGIN_NOT_ALLOWED` | The page's origin is not exactly one of the key's allowed origins. Check scheme, host and port. |
| A request fails with 403 `FORBIDDEN` | The session's `components` do not include that builder, or the route is outside what sessions may call. |
| Minting returns 404 | The `Kirora-Workspace` value is not one of your tenants' `externalId`s. |
| A partner-key call returns 403 `FORBIDDEN` ("A partner key is for servers") | The request carried an `Origin` header, as browser requests do. Call the API with the partner key from your server only. |
| A session expires mid-edit | Nothing to do: the provider asks `fetchSession` for a new one before expiry, and again after a 401. |
| A save is refused with 400 `VALIDATION_ERROR` | Pass the response body to the builder as `errors`; each issue whose path points into the blocks or conditions is marked there, and the rest are listed above them. |

See [Errors](/docs/api/errors) for the error body and the full code catalogue.