Menu

React builders

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

Preview

@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 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:

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, 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:

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

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 or 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 and 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:

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’ externalIds.
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 for the error body and the full code catalogue.