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_xxxxxxxxxxxx

Keys 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).

EndpointBody / queryResponse
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.

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

PurposeTypeNameValue
DKIMTXTkm1._domainkey.acme.comv=DKIM1; k=rsa; p=MIIBIj…
Return pathCNAMEbounces.acme.comrp.kubemail.app
DMARC (recommended)TXT_dmarc.acme.comv=DMARC1; p=none; rua=mailto:dmarc@kubemail.app
Tracking (recommended)CNAMEtrack.acme.comtrack.kubemail.app
Inbound (optional)MX 10inbox.acme.commta1.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
};
EndpointBody / queryResponse
POST/v1/emailsSendEmail202 { id: "msg_…", status: "queued" }
POST/v1/emails/batchSendEmail[] (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

EndpointBody / queryResponse
GET/v1/emailsstatus?, 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

EndpointBody / queryResponse
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.

EndpointBody / queryResponse
GET/v1/suppressionsemail?, 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.

EndpointBody / queryResponse
GET/v1/inbox/mailboxes–{ data: [...], unread }
GET/v1/inbox/threadsfolder=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.