KT8 Mail Install now
For developers

The KT8 Mail API and webhooks

Connect your own apps to KT8 Mail. Read, search and send email with a simple JSON API, and get told the moment new email arrives. Everything runs on your own Cloudflare account, so your mail never passes through us.

Get started

The API comes with the latest version of KT8 Mail. It lives on your own install, at your own web address:

https://mail.yourdomain.co.uk/api/ext/v1
  1. Sign in to KT8 Mail as an admin and open Admin › API & webhooks. You'll see your API address there, ready to copy.
  2. If it says the API isn't switched on yet, run the installer again at kt8.com/install. It lets the API's address past Cloudflare sign-in (just that address; the rest of KT8 Mail stays behind sign-in) and switches the API on.
  3. Choose New API key, tick what the key can do and which mailboxes it can use, and copy the key. It's shown once.
  4. Send the key with each request, in an Authorization header:
curl https://mail.yourdomain.co.uk/api/ext/v1/mailboxes \
  -H "Authorization: Bearer kt8_ab12cd34_••••••••••••••••••••••••••••••••"

Every request and answer is JSON. Times are in UTC, written like 2026-10-15T09:30:00.000Z.

API keys and permissions

Make a separate key for each app, so you can delete one without stopping the others. Each key:

  • only reaches the mailboxes you ticked when you made it. Any other mailbox looks as if it doesn't exist;
  • only does what you allowed, from the three permissions below;
  • is stored only as a fingerprint (a SHA-256 hash), so nobody, not even an admin, can see it again after it's made. Lost a key? Delete it and make a new one.
PermissionWhat it allows
ReadList mailboxes, list, read and search email, read whole conversations, see folders and labels, and download attachments.
SendSend new emails, replies and forwards from the key's mailboxes (and their extra addresses).
OrganiseMark email as read or unread, star it, move it to a folder, and add or remove labels.

The API never deletes email, changes settings, or manages people, mailboxes or domains. Those stay in the app, behind sign-in.

Every request

Write mailbox addresses in the path with @ as %40, for example /mailboxes/sales%40yourdomain.co.uk/emails.

RequestNeedsWhat it does
GET /mailboxesReadThe mailboxes this key can use.
GET /mailboxes/{mailbox}/foldersReadFolders, with how many unread emails each has. The built-in ones are inbox, sent, draft, archive, spam and trash (shown as Bin).
GET /mailboxes/{mailbox}/emails?folder=inbox&page=1&limit=25ReadA page of emails in a folder, newest first, with totalCount. Add &threaded=true for one row per conversation.
GET /mailboxes/{mailbox}/emails/{id}ReadOne email in full, with its body and attachments list.
GET /mailboxes/{mailbox}/threads/{thread_id}ReadEvery email in a conversation, oldest first.
GET /mailboxes/{mailbox}/search?query=invoiceReadSearch. Also takes from, to, subject, folder, label, date_start, date_end, is_read, is_starred, has_attachment, page and limit.
GET /mailboxes/{mailbox}/labelsReadThe mailbox's labels.
GET /mailboxes/{mailbox}/emails/{id}/attachments/{attachment_id}ReadDownloads an attachment (the file itself, not JSON).
POST /mailboxes/{mailbox}/emailsSendSends a new email. See the example below.
POST /mailboxes/{mailbox}/emails/{id}/replySendReplies to an email, keeping it in the same conversation. Same body as sending.
POST /mailboxes/{mailbox}/emails/{id}/forwardSendForwards an email. Same body as sending.
PUT /mailboxes/{mailbox}/emails/{id}OrganiseMarks as read or starred: {"read": true, "starred": false}.
POST /mailboxes/{mailbox}/emails/{id}/moveOrganiseMoves to a folder: {"folderId": "archive"}.
PUT /mailboxes/{mailbox}/emails/{id}/labelsOrganiseAdds and removes labels on the whole conversation: {"add": ["label-id"], "remove": []}.

Examples

Read the newest emails in an inbox

curl "https://mail.yourdomain.co.uk/api/ext/v1/mailboxes/sales%40yourdomain.co.uk/emails?folder=inbox&limit=10" \
  -H "Authorization: Bearer $KT8_API_KEY"
{
  "emails": [
    {
      "id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "thread_id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "subject": "Order 1234 question",
      "sender": "jo@example.org",
      "sender_name": "Jo Bloggs",
      "recipient": "sales@yourdomain.co.uk",
      "date": "2026-10-15T09:30:00.000Z",
      "read": false,
      "starred": false
    }
  ],
  "totalCount": 1
}

Send an email

curl https://mail.yourdomain.co.uk/api/ext/v1/mailboxes/sales%40yourdomain.co.uk/emails \
  -H "Authorization: Bearer $KT8_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": { "email": "sales@yourdomain.co.uk", "name": "Greenleaf Sales" },
    "to": "jo@example.org",
    "subject": "Thanks for your order",
    "text": "We've got your order and will be in touch soon.",
    "html": "<p>We've got your order and will be in touch soon.</p>"
  }'

to, cc and bcc take one address or a list. from must be the mailbox or one of its extra addresses. To attach a file, add "attachments": [{"filename": "invoice.pdf", "type": "application/pdf", "content": "<base64>", "disposition": "attachment"}]. The answer is {"id": "…", "status": "sent"}, and the email appears in the mailbox's Sent folder like any other.

Sending to other people needs Cloudflare's Workers Paid plan ($5 a month) and your domain set up for sending, the same as sending from the app. If Cloudflare refuses an email, the answer says why in plain English.

In JavaScript

const res = await fetch(`${KT8_URL}/mailboxes/${encodeURIComponent("sales@yourdomain.co.uk")}/search?query=invoice`, {
  headers: { Authorization: `Bearer ${process.env.KT8_API_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error);
const { emails } = await res.json();

Webhooks

A webhook tells another app's web address the moment something happens, so it doesn't have to keep asking. Add one in Admin › API & webhooks › New webhook: give its https:// address, tick when to send it, and choose all mailboxes or just some.

EventWhen it's sent
email.receivedAn email arrives in a mailbox. Not for spam, or for email an inbox rule moves to the Bin.
email.sentAn email, reply or forward is sent from a mailbox, from the app or the API.

KT8 Mail sends a POST with a JSON body like this:

{
  "id": "evt_c7e1c6da-0e04-4a56-94b8-9e9ca5be6b70",
  "type": "email.received",
  "created": "2026-10-15T09:30:01.000Z",
  "mailbox": "sales@yourdomain.co.uk",
  "data": {
    "email": {
      "id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "thread_id": "f332437e-582c-4e8f-b514-db180c5e2a91",
      "folder": "inbox",
      "subject": "Order 1234 question",
      "from": "jo@example.org",
      "from_name": "Jo Bloggs",
      "to": "sales@yourdomain.co.uk",
      "cc": null,
      "date": "2026-10-15T09:30:00.000Z",
      "has_attachments": false
    }
  }
}

The body is a summary, not the whole email, so private mail isn't copied around more than it needs to be. Use the API with the email's id to read the rest.

Each request also carries these headers:

KT8-EventThe event, such as email.received, or test for the Send a test button.
KT8-DeliveryThe same for every try of one event, so you can ignore repeats.
KT8-Signaturet=1760520601,v1=5f2b…: when it was sent and its signature. See below.

Answer with any 2xx status within 10 seconds. If your app can't be reached, takes too long, or answers 408, 429 or 5xx, KT8 Mail tries again after 1 minute, 10 minutes and 1 hour. Other 4xx answers aren't tried again. Every try shows in the webhook's Delivery log, and you can pause a webhook at any time.

Checking a webhook is genuine

Each webhook has its own signing secret (starting whsec_), shown when you add it and in its menu. KT8 Mail signs <t>.<body> with HMAC-SHA256 using that secret. To check a request, work out the same signature from the raw body and compare it with v1, and refuse anything more than five minutes old.

Node.js

import crypto from "node:crypto";

function isFromKT8(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.v1 || age > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Python

import hashlib, hmac, time

def is_from_kt8(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if "v1" not in parts or abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Use the body exactly as it arrived. Parsing the JSON and turning it back into text can change it, and then the signature won't match.

Errors and limits

Anything that goes wrong comes back with a status and a sentence you can show to a person: {"error": "That API key isn't valid. It may have been deleted."}

StatusMeans
400Something in the request isn't right, such as a from address the mailbox can't send as.
401No key, or a key that isn't valid any more.
403The key doesn't have the permission this needs.
404Not found, a mailbox the key can't use, or a request the API doesn't have.
413The email is too big to send. Cloudflare allows up to 25 MB including attachments.
429Too many emails sent too quickly. Wait a little and try again.
502Cloudflare refused to send the email. The error says why.
503The API isn't switched on for this install yet. Run the installer again.

Each install can have up to 25 API keys and 10 webhooks. Requests made from a web page in someone's browser aren't allowed, because the key would be visible to anyone using the page. Call the API from your own server, or from tools like Zapier, Make or n8n.

Questions or ideas? Ask in the Community or email hello@kt8.com.