---
title: "Backend integration, MailKaro Docs"
description: "Add email-craft-sdk to your backend and expose a few small endpoints: plan, AI, uploads and white-label then work in the editor, with no secret in the browser."
---

# Backend integration

Getting started

# Backend integration

Everything paid runs server-side. Add `email-craft-sdk` to your backend, expose a few small endpoints, and the embedded editor lights up, plan, AI, uploads, white-label, with **no secret ever in the browser** and no per-feature frontend code.

## Set up your backend in five steps

- **Install the SDK.** Run npm install email-craft-sdk on your backend.
- **Create one client.** Set MAILKARO_SERVER_KEY, then build a single shared client with Mailkaro.fromEnv(process.env).
- **Add the routes.** Expose GET /embed-runtime, which returns resolveRuntime(). Add POST /ai and POST /upload if you want AI and uploads, and GET /s3/list for the optional bucket browser.
- **Allow the editor origin.** Set CORS to reflect https://embed.mailkaro.com with credentials, and allow the organization-id header if you use it.
- **Point the editor at your runtime URL.** Pass your pk_live_ token and runtimeUrl to EmailEditor on the frontend.

## Why a backend

The editor renders in MailKaro's hosted iframe (`embed.mailkaro.com`) and holds **no secret of its own**. Your backend resolves the runtime server-to-server with the SECRET server key `sk_live_…` (`MAILKARO_SERVER_KEY`), the private half of your [token pair](https://docs.mailkaro.com/embed-tokens); the publishable `pk_live_…` goes on the frontend. You never send an org id, the org resolves from the server key itself.

## Install

bash

```
npm install email-craft-sdk
# or pnpm add email-craft-sdk / yarn add email-craft-sdk
```

## Create the client

One memoized instance, built from your environment (see the variables below):

ts mailkaro.ts

```
import { Mailkaro } from "email-craft-sdk";

// Reads MAILKARO_SERVER_KEY + optional AI / S3 / branding vars. Reuse this one instance.
export const mailkaro = Mailkaro.fromEnv(process.env);
```

✦ `fromEnv` throws if `MAILKARO_SERVER_KEY` is missing. If the editor is optional in your app, build it defensively (`try/catch` → `null`) and expose a `requireClient()` that returns a clean **503**, a missing optional integration must never crash boot.

## The endpoints the editor calls

You give the editor one `runtimeUrl` (e.g. `/mailkaro/embed-runtime`); it derives the sibling `…/ai` and `…/upload` URLs itself (same origin, last path segment swapped). All four are **public**, the iframe carries none of your session cookies, optionally scoped by a per-tenant header you choose (see the note below) and gated by the token, never by a login.

ts mailkaro.routes.ts, Express

```
import { Router } from "express";
import multer from "multer";
import { mailkaro } from "./mailkaro";

export const router = Router();
const upload = multer(); // memory storage → file.buffer

// 1) Runtime: plan + entitlements + capabilities (what the editor is allowed to do). Required.
router.get("/embed-runtime", async (req, res) => {
 res.json(await mailkaro.resolveRuntime({ project: String(req.query.project ?? "") }));
});

// 2) BYO AI, the provider key stays here. Body { method, input }.
router.post("/ai", async (req, res) => {
 try {
 res.json(await mailkaro.ai(req.body?.method, req.body?.input ?? {}));
 } catch (e) {
 res.status(400).json({ error: e instanceof Error ? e.message : "AI request failed" });
 }
});

// 3) Drag-and-drop upload → YOUR S3. Multipart field "file".
router.post("/upload", upload.single("file"), async (req, res) => {
 if (!req.file) return res.status(400).json({ error: "file is required" });
 const orgId = String(req.header("organization-id") ?? "").trim();
 if (!orgId) return res.status(400).json({ error: "organization-id header is required" });
 try {
 // scopeId is REQUIRED (per-tenant isolation). The SDK also enforces an image-type allowlist,
 // a size cap, and sanitizes SVGs; a rejected upload throws and is surfaced as a 400 below.
 res.json(await mailkaro.uploadAsset({
 buffer: req.file.buffer, filename: req.file.originalname, contentType: req.file.mimetype, scopeId: orgId, }));
 } catch (e) {
 res.status(400).json({ error: e instanceof Error ? e.message : "Upload failed" });
 }
});

// 4) OPTIONAL, the "browse my S3" tab. Presign with YOUR credentials.
router.get("/s3/list", async (req, res) => {
 const org = String(req.header("organization-id") ?? "").trim();
 if (!org) return res.json({ items: [] });
 const prefix = mailkaro.s3AssetPrefix(org);
 const objects = await listObjects(prefix); // your S3 SDK
 res.json(await mailkaro.scopeS3Assets(org, objects, (key) => getSignedUrl(key)));
});
```

Or, in NestJS, the same four routes as a controller:

ts mailkaro.controller.ts, NestJS

```
@Controller("mailkaro")
export class MailkaroController {
 constructor(private readonly mailkaro: MailkaroService) {} // .client = Mailkaro.fromEnv(process.env)

 @Get("embed-runtime")
 runtime(@Query("project") project?: string) {
 return this.mailkaro.client.resolveRuntime({ project });
 }

 @Post("ai")
 ai(@Body() body: { method: string; input?: Record<string, unknown> }) {
 return this.mailkaro.client.ai(body.method, body.input ?? {});
 }

 @Post("upload")
 @UseInterceptors(FileInterceptor("file"))
 upload(@UploadedFile() file: any, @Headers("organization-id") org?: string) {
 const scopeId = org?.trim();
 if (!scopeId) throw new BadRequestException("organization-id header is required");
 return this.mailkaro.client.uploadAsset({
 buffer: file.buffer, filename: file.originalname, contentType: file.mimetype, scopeId, });
 }
}
```

|Prop |Type |Default |Description |
|`GET /mailkaro/embed-runtime` |resolveRuntime() |- |Plan, entitlements, merge tags + `capabilities`. Required. |
|`POST /mailkaro/ai` |ai(method, input) |- |BYO AI, server-to-server. Powers the [AI helper](https://docs.mailkaro.com/ai-providers). |
|`POST /mailkaro/upload` |uploadAsset(file) |- |Drag-drop images to your S3. See [image uploads](https://docs.mailkaro.com/image-uploads). |
|`GET /mailkaro/s3/list` |scopeS3Assets(…) |- |Optional “browse my bucket” tab. See [Amazon S3](https://docs.mailkaro.com/amazon-s3). |

ℹ **The tenant header is yours.** `organization-id` above is just an example name for a per-tenant scope you send on the upload / S3 routes so one tenant's assets never mix with another's, call it whatever you like and read it the same way in your routes. Building a single-tenant app? Omit it entirely and drop `scopeId` / `connectorHeaders`.

## CORS

The iframe fetches with `credentials: "include"`, so reflect the exact origin (never `*`) and allow the `organization-id` header:

ts cors.ts

```
app.use(cors({
 origin: "https://embed.mailkaro.com", // the hosted editor's origin
 credentials: true, allowedHeaders: ["Content-Type", "organization-id"],
}));
```

## Wire the editor to your backend

On the frontend, point the editor at your runtime URL, that's all it needs:

tsx Composer.tsx

```
<EmailEditor
 token={pk} // pk_live_… (publishable)
 runtimeUrl={`${API}/mailkaro/embed-runtime`} // your backend
 connectorHeaders={{ "organization-id": orgId }} // optional: your per-tenant scope header
 mergeTags={mergeTags}
/>
```

✦ The editor turns on AI, uploads, and white-label **automatically** from whatever the runtime advertises in `capabilities`, no per-feature frontend code. For local dev only, set `embedUrl` to a locally-run editor host; in production leave it unset (defaults to the hosted editor).

## Environment variables

`Mailkaro.fromEnv(process.env)` reads:

|Prop |Type |Default |Description |
|`MAILKARO_SERVER_KEY` |required |- |Your secret server key `sk_live_…`. The whole integration hangs off this. |
|`OPEN_AI_KEY / MAILKARO_AI_KEY / CLAUDE_AI_KEY` |→ AI |- |Your provider key (BYO AI). Any one enables the AI capability. |
|`MAILKARO_S3_BUCKET + MAILKARO_S3_REGION` |→ upload |- |Your S3 bucket + region for drag-drop uploads (standard AWS credential chain). |
|`MAILKARO_S3_PUBLIC_BASE_URL` |optional |- |Serve uploaded assets from a CDN / custom domain instead of the raw S3 URL. |
|`MAILKARO_S3_PREFIX` |optional |- |Key prefix for editor assets (defaults to `mailkaro/`). |
|`MAILKARO_LOGO_URL / MAILKARO_LOGO_ALT` |→ white-label |- |Show your own logo in the editor instead of the MailKaro badge. |
|`MAILKARO_API_URL` |rarely set |- |Defaults to MailKaro's hosted API. |
|`MAILKARO_ORG_ID` |leave unset |- |Not needed, the org resolves from the server key (token-only). |

ℹ **What you DON'T need:** no org id, no frontend AI or upload code, no S3 bucket CORS (uploads proxy through your backend), and no secret in the browser, just the server key, this small endpoint set, and a `runtimeUrl` on the editor.

Next: [Embed tokens](https://docs.mailkaro.com/embed-tokens) · [AI](https://docs.mailkaro.com/ai-providers) · [Amazon S3](https://docs.mailkaro.com/amazon-s3) · [Security](https://docs.mailkaro.com/security).

## Frequently asked questions

Which key goes where?

The secret server key (sk_live_) stays on your backend, in MAILKARO_SERVER_KEY. The publishable key (pk_live_) goes on the frontend. A secret key never reaches the browser.

Which environment variable is required?

Only MAILKARO_SERVER_KEY. Mailkaro.fromEnv throws if it is missing. The AI, S3 and white-label variables are optional and switch those capabilities on.

Do I need to send an organization id?

No. The organization resolves from the server key itself.

Source: https://docs.mailkaro.com/backend
