KubeMail API
One REST API for transactional email, batches, broadcasts and the mailbox. JSON in, JSON out, and if it can make an HTTPS request, it can send through KubeMail.
Base URL: https://api.kubemail.app. All endpoints below live under /v1, and times are ISO-8601 UTC strings.
curl -X POST https://api.kubemail.app/v1/emails \
-H "Authorization: Bearer km_live_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": "ada@example.com",
"subject": "Welcome to Acme",
"html": "<p>Thanks for signing up!</p>"
}'HTTP/1.1 202 Accepted
{ "id": "msg_2Hc9xTq4Lk", "status": "queued" }Authentication
Create an API key in the dashboard under Developers → API keys and send it as a bearer token. The token (km_live_…) is shown once, so store it in a secret manager.
Authorization: Bearer km_live_xxxxxxxxxxxxKeys are either full_access (every /v1 route except account and billing) or sending_access (only POST /v1/emails* and POST /v1/broadcasts, optionally pinned to one domain).
| Endpoint | Body / query | Response |
|---|---|---|
GET/v1/api-keys | – | { data: ApiKey[] } |
POST/v1/api-keys | { name, permission, domain_id?, expires_at? } | 201 { api_key, token } |
DELETE/v1/api-keys/:id | – | 204 |
Errors & pagination
Any non-2xx response has the same shape. Validation errors use 422 and may list the failing fields.
{
"error": {
"code": "validation_error",
"message": "Human readable",
"details": [{ "path": "to[0]", "message": "Invalid email" }]
}
}List endpoints take ?limit=50&cursor=… and return { data: [...], next_cursor }. Pass next_cursor back until it is null.
Rate limits & idempotency
When you exceed your rate limit the API returns 429 with a Retry-After header. Send endpoints also return X-RateLimit-Limit and X-RateLimit-Remaining.
Send endpoints accept an Idempotency-Key header. Any retry with the same key within 24 hours returns the original response instead of sending again.
Domains
Add a domain, publish the DNS records we generate, then verify. A domain is verified once every required record checks out.
| Endpoint | Body / query | Response |
|---|---|---|
GET/v1/domains | – | { data: Domain[] } |
POST/v1/domains | { name } | 201 Domain |
GET/v1/domains/:id | – | Domain |
POST/v1/domains/:id/verify | – | Domain (live DNS check) |
PATCH/v1/domains/:id | { tracking: { opens?, clicks? } } | Domain |
DELETE/v1/domains/:id | – | 204 |
Records generated for acme.com:
| Purpose | Type | Name | Value |
|---|---|---|---|
| DKIM | TXT | km1._domainkey.acme.com | v=DKIM1; k=rsa; p=MIIBIj… |
| Return path | CNAME | bounces.acme.com | rp.kubemail.app |
| DMARC (recommended) | TXT | _dmarc.acme.com | v=DMARC1; p=none; rua=mailto:dmarc@kubemail.app |
| Tracking (recommended) | CNAME | track.acme.com | track.kubemail.app |
| Inbound (optional) | MX 10 | inbox.acme.com | mta1.kubemail.app |
The return-path CNAME gives you SPF and bounce alignment without touching your root SPF record, and your root MX is never changed.
Sending email
type SendEmail = {
from: string; // "Acme <hi@acme.com>", domain must be verified
to: string | string[]; // up to 50 recipients
cc?: string | string[];
bcc?: string | string[];
reply_to?: string | string[];
subject: string;
html?: string; // at least one of html / text
text?: string;
headers?: Record<string, string>;
tags?: Record<string, string>; // up to 10, used for filtering
variables?: Record<string, string | number | boolean>; // {{ var }} substitution
};| Endpoint | Body / query | Response |
|---|---|---|
POST/v1/emails | SendEmail | 202 { id: "msg_…", status: "queued" } |
POST/v1/emails/batch | SendEmail[] (up to 1,000) | 202 { data: [{ id }] }, all-or-nothing |
Recipients on your suppression list are dropped with a rejected event.
Broadcasts
Send one message to up to 100,000 recipients, each with their own variables.
POST /v1/broadcasts
{
"from": "Acme <news@acme.com>",
"subject": "October product update, {{ first_name }}",
"html": "<h1>Hi {{ first_name }}</h1>…",
"recipients": [
{ "email": "ada@example.com", "variables": { "first_name": "Ada" } }
]
}
202 { "id": "bc_…", "accepted": 1, "suppressed": 0, "status": "queued" }Logs & events
| Endpoint | Body / query | Response |
|---|---|---|
GET/v1/emails | status?, recipient?, tag?, from_date?, to_date?, limit?, cursor? | { data: EmailLog[], next_cursor } |
GET/v1/emails/:id | – | { email: EmailLog, events: EmailEvent[] } |
Statuses: queued, sent, delivered, deferred, bounced, complained, rejected, opened, clicked, expired. Each event carries the SMTP code, enhanced code and raw response where there is one.
Webhooks
| Endpoint | Body / query | Response |
|---|---|---|
GET/v1/webhooks | – | { data: Webhook[] } |
POST/v1/webhooks | { url, events: EventType[] } | 201 { webhook, secret } |
DELETE/v1/webhooks/:id | – | 204 |
Each delivery is a POST of { type, created_at, data: { email_id, recipient, … } }, signed with the secret returned on creation (shown once):
// KubeMail-Signature: t=<unix>,v1=<hex hmac_sha256(secret, "<t>.<body>")>
import crypto from "node:crypto";
function verify(secret, header, rawBody) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Suppressions
Hard bounces, complaints and unsubscribes are added automatically. You can also manage the list yourself.
| Endpoint | Body / query | Response |
|---|---|---|
GET/v1/suppressions | email?, limit?, cursor? | { data: Suppression[], next_cursor } |
POST/v1/suppressions | { email } | 201 Suppression |
DELETE/v1/suppressions/:email | – | 204 |
Mailbox
With the inbound MX record in place, mail to <mailbox>@inbox.<domain> is stored and threaded per address. Replies you send go out from <mailbox>@<domain> with a reply-to on the inbox domain, so the conversation comes back.
| Endpoint | Body / query | Response |
|---|---|---|
GET/v1/inbox/mailboxes | – | { data: [...], unread } |
GET/v1/inbox/threads | folder=inbox|sent|all, domain_id?, mailbox?, unread?, q? | { data: Thread[], next_cursor } |
GET/v1/inbox/threads/:id | – | { thread, messages } |
PATCH/v1/inbox/threads/:id | { read: boolean } | { thread } |
DELETE/v1/inbox/threads/:id | – | 204 |
GET/v1/inbox/messages/:id/attachments/:att | – | file |
POST/v1/inbox/messages | { reply_to_message_id?, domain_id?, mailbox?, to, subject?, html?, text? } | 202 { id, thread_id, email_id, status } |
Inbound HTML is untrusted, so render it in a sandboxed frame.
Stats
GET /v1/stats?range=24h|7d|30d returns totals, rates (fractions from 0 to 1), a time series (hourly for 24h, daily otherwise) and your usage for the current period.
Questions? Contact support.