@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.
- Your server holds a partner key. A partner key is server-only: a request that carries an
Originheader is refused, so it cannot be used from a browser. - Your server mints an embed session. When a page needs a builder, your server calls
POST /v1/embed-sessionswith the partner key and aKirora-Workspaceheader naming the tenant’sexternalId. Kirora returns a session token (es_…) that lasts 15 minutes. - The browser only holds the session.
KiroraProvidercalls yourfetchSessionfunction to get one, and calls it again a minute before it expires, or once more if the API answers 401. - 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 asexternalId. - A partner key with the
workspaces:managescope. 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 ashttp://localhost:5173. Minting a session with a key that has no allowed origins is refused with 409EMBED_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.