Appearance
JavaScript / TypeScript
No official SDK yet
Krafter Mail does not ship an official JavaScript or TypeScript SDK. The HTTP API is small and stable, so the recommended approach today is to call it directly with fetch (Node 18+, Deno, Bun, browsers via a server-side proxy). This page shows working snippets that mirror the Mail API reference.
If a server-side proxy is not used, do not put your API key in browser code — it grants full mail-send access for your team.
Base URL & authentication
All endpoints live under:
https://app.krafter.dev/api/v1Every request needs a Bearer token:
http
Authorization: Bearer kr_live_abc123def456The single key prefix is kr_live_. The sandbox flag is set on the key itself (in the dashboard) — it is not a separate prefix.
A minimal client
A small wrapper avoids repeating the URL and headers. Drop this into a file and import it where you need it:
ts
// krafterMail.ts
const BASE_URL = "https://app.krafter.dev/api/v1";
export type SendEmailBody = {
from: string;
to: string | string[];
subject: string;
html?: string;
text?: string;
cc?: string[];
bcc?: string[];
reply_to?: string;
tags?: Record<string, string>;
attachments?: Array<{
filename: string;
content_type: string;
content: string; // base64-encoded
}>;
stream?: "transactional" | "broadcast";
tracking_enabled?: boolean;
headers?: Record<string, string>;
scheduled_at?: string; // ISO 8601 UTC
template_id?: string;
variables?: Record<string, string>;
};
export async function krafterFetch(
path: string,
init: RequestInit & { idempotencyKey?: string } = {},
) {
const apiKey = process.env.KRAFTER_API_KEY;
if (!apiKey) throw new Error("KRAFTER_API_KEY is not set");
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${apiKey}`);
headers.set("Content-Type", "application/json");
if (init.idempotencyKey) headers.set("Idempotency-Key", init.idempotencyKey);
const res = await fetch(`${BASE_URL}${path}`, { ...init, headers });
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) {
throw Object.assign(new Error(body?.error ?? `HTTP ${res.status}`), {
status: res.status,
body,
headers: res.headers,
});
}
return body;
}Sending email
Basic send
ts
const { data: email } = await krafterFetch("/emails", {
method: "POST",
body: JSON.stringify({
from: "hello@yourdomain.com",
to: ["alice@example.com"],
subject: "Hello from Krafter Mail",
html: "<h1>Welcome!</h1><p>Your first email.</p>",
stream: "transactional",
} satisfies SendEmailBody),
});
console.log(`queued: ${email.id} (${email.status})`);Idempotency
Pass Idempotency-Key as a header, not a body field. Replays of the same key within 24 hours return the original record without re-sending. Keys must be ≤ 255 bytes.
ts
const { data: email } = await krafterFetch("/emails", {
method: "POST",
idempotencyKey: `order-${order.id}-confirmation`,
body: JSON.stringify({
from: "orders@yourdomain.com",
to: order.customerEmail,
subject: `Your order #${order.id}`,
html: orderHtml(order),
stream: "transactional",
}),
});Streams
Every send is routed through one of two streams:
transactional—tracking_enabled: falseby default, noList-Unsubscribeheader.broadcast—tracking_enabled: trueby default,List-Unsubscribeheader added.
If your API key is allowed to send on both streams, the stream field is required — omitting it returns 422 stream_required. If your key is restricted to a single stream, omit the field and the allowed stream is used automatically.
Per-message tracking override
Override the resolved stream's default tracking flag for a single message. Sandbox keys ignore the override (sandbox always uses a fixed policy).
ts
// Opt this single transactional send into open/click tracking
await krafterFetch("/emails", {
method: "POST",
body: JSON.stringify({
from: "support@yourdomain.com",
to: "alice@example.com",
subject: "Did this help?",
html: feedbackEmail(),
stream: "transactional",
tracking_enabled: true,
}),
});Custom MIME headers
Pass a headers object to add custom outbound headers. Reserved names (From, To, Cc, Bcc, Subject, Date, Message-ID, Content-Type, Content-Transfer-Encoding, MIME-Version, DKIM-Signature, Authorization, anything starting with X-Krafter-) return 422 forbidden_header.
ts
await krafterFetch("/emails", {
method: "POST",
body: JSON.stringify({
from: "billing@yourdomain.com",
to: "alice@example.com",
subject: "Invoice #2048",
html: "<p>Please find your invoice attached.</p>",
stream: "transactional",
headers: {
"X-Entity-Ref-ID": "inv-2048",
},
}),
});Templates
Server-rendered templates accept template_id (UUID of a saved template) plus variables for substitution. The template's subject, html, and text replace the request fields after rendering.
ts
await krafterFetch("/emails", {
method: "POST",
body: JSON.stringify({
from: "orders@yourdomain.com",
to: "alice@example.com",
subject: "ignored when template_id is set",
template_id: "01J7...your-template-uuid",
variables: {
customer_name: "Alice",
order_id: "ORD-1042",
},
stream: "transactional",
}),
});Batch send
ts
const { data: results } = await krafterFetch("/emails/batch", {
method: "POST",
body: JSON.stringify({
emails: [
{ from: "hello@yourdomain.com", to: "alice@example.com", subject: "Hi Alice", html: "<p>...</p>", stream: "transactional" },
{ from: "hello@yourdomain.com", to: "bob@example.com", subject: "Hi Bob", html: "<p>...</p>", stream: "transactional" },
],
}),
});
for (const item of results) {
if (item.status === 201) {
console.log(`queued: ${item.data.id}`);
} else {
console.warn(`failed:`, item.errors);
}
}The wrapper status is 201 Created if every item succeeded, 207 Multi-Status if some succeeded and some failed at insert time. Up to 100 items per batch.
Listing and retrieving
List emails
ts
const url = new URL("https://app.krafter.dev/api/v1/emails");
url.searchParams.set("status", "delivered");
url.searchParams.set("per_page", "25");
const { data, meta } = await krafterFetch(url.pathname + url.search);
console.log(`page ${meta.page} of ${Math.ceil(meta.total / meta.per_page)}`);Supported query parameters: page, per_page (capped at 100), status, search (matches from, to, or subject). Date-range filters are not supported.
Get one email
ts
const { data: email } = await krafterFetch(`/emails/${id}`);
console.log(email.status, email.delivered_at);Get email events
ts
const { data: events } = await krafterFetch(`/emails/${id}/events`);
for (const ev of events) console.log(ev.type, ev.occurred_at);Cancel a queued email
Only emails in queued status with a future scheduled_at can be cancelled.
ts
await krafterFetch(`/emails/${id}/cancel`, { method: "POST" });Domains, templates, webhooks, statistics
The same krafterFetch helper works for every resource. See:
- Domains API —
GET/POST /mail/domains,POST /mail/domains/:id/verify. - Templates API —
GET/POST /mail/templates, etc. - Webhooks API — endpoints live under
/api/v1/mail/webhooks/*. - Statistics API —
GET /mail/statsandGET /mail/stats/daily?days=N.
Verifying webhook signatures
Krafter signs every webhook delivery with HMAC-SHA256 over the raw request body and includes the signature in the x-krafter-signature header as sha256=<hex>. Verify with node:crypto against the raw body — once you JSON.parse, you cannot reconstruct the bytes that were signed.
ts
import crypto from "node:crypto";
export function verifyKrafterSignature(rawBody: Buffer, header: string, secret: string) {
// Header format: "sha256=<lowercase-hex>"
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const provided = header.startsWith("sha256=") ? header.slice("sha256=".length) : header;
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(provided, "hex"));
}The delivery also carries an x-krafter-webhook-id header with a UUID that is fresh per delivery attempt — use it as your dedup key. See Webhooks API for the full event list.
Error handling
Errors surface as one of four shapes documented in Error Handling. With the helper above, the thrown error has status, body, and headers:
ts
try {
await krafterFetch("/emails", { method: "POST", body: JSON.stringify(body) });
} catch (err) {
switch (err.status) {
case 401:
// { "error": "Invalid or missing API key" }
console.error("auth failed");
break;
case 403:
case 422:
// { "error": "...", "code": "stream_not_allowed" | "forbidden_header" | ... }
console.error("policy/validation:", err.body?.code, err.body?.error);
break;
case 429: {
// { "error": "Rate limit exceeded", "retry_after": 12 }
const retryAfter = Number(err.headers.get("retry-after") ?? err.body?.retry_after ?? 1);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
break;
}
default:
throw err;
}
}The 429 response sets the standard Retry-After header (seconds) and includes retry_after in the body. The full policy code enum (stream_required, stream_not_allowed, forbidden_header, sandbox_extra_recipients, recipient_not_allowed, …) is listed in Error Handling.
Framework snippets
Next.js (App Router) API route
ts
// app/api/send-welcome/route.ts
import { NextResponse } from "next/server";
import { krafterFetch } from "@/lib/krafterMail";
export async function POST(request: Request) {
const { email, name } = await request.json();
const { data } = await krafterFetch("/emails", {
method: "POST",
idempotencyKey: `welcome-${email}`,
body: JSON.stringify({
from: "welcome@yourdomain.com",
to: email,
subject: `Welcome, ${name}`,
html: `<h1>Welcome, ${name}</h1>`,
stream: "transactional",
}),
});
return NextResponse.json({ emailId: data.id });
}Express handler
ts
import express from "express";
import { krafterFetch } from "./krafterMail";
const app = express();
app.use(express.json());
app.post("/send-receipt", async (req, res, next) => {
try {
const { data } = await krafterFetch("/emails", {
method: "POST",
idempotencyKey: `receipt-${req.body.orderId}`,
body: JSON.stringify({
from: "receipts@yourdomain.com",
to: req.body.to,
subject: `Receipt for order #${req.body.orderId}`,
html: req.body.html,
stream: "transactional",
}),
});
res.json({ emailId: data.id, status: data.status });
} catch (err) {
next(err);
}
});