Data, retention & encryption
Your webhook is the system of record — MailKite is the pipe. You decide, per domain, how much (if anything) we keep: short auto-expiring retention by default, zero-retention passthrough to keep nothing, or at-rest encryption with your own key so we store it but can't read it.
Retention windows
By default we keep a parsed copy of each message just long enough for you to replay or debug a delivery, then it auto-expires. No action needed.
| Plan | Message retention |
|---|---|
| Free | 3 days |
| Pro | 30 days |
| Scale | 90 days |
| Business | 365 days |
Inbound attachments are stored for 7 days, then deleted by lifecycle rule.
Zero-retention passthrough
Turn this on for a domain and MailKite delivers the webhook but persists nothing: no message body, no attachments, no delivery record. Your webhook holds the only copy. Best for privacy-sensitive inboxes where you never want a server-side copy to exist.
Toggle it on the domain in the dashboard, or via the API:
curl -X PUT https://api.mailkite.dev/api/domains/DOMAIN_ID/retention \
-H "authorization: Bearer mk_live_3a9f…" \
-H "content-type: application/json" \
-d '{"zeroRetention": true}' With passthrough on:
- The first (and only) webhook delivery happens as usual, over TLS, signed.
- There's no replay and no dashboard history for that domain — nothing was stored to replay.
-
Attachments are inlined into the webhook as base64
contentinstead of a signedurl(there's no stored object to link to):
{
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"content": "JVBERi0xLjQKJ…" // base64 of the file — no signed URL, nothing stored
} At-rest encryption
The middle ground: we keep the message for the normal retention window, but the body is encrypted to your public key before it's written, so MailKite can never read it. Only you hold the private key. Encryption is per-domain and opt-in.
1. Generate a keypair
# Generate a 2048-bit RSA keypair. Keep private.pem secret — it's the only thing
# that can ever read your encrypted mail. Paste the contents of public.pem into MailKite.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
openssl rsa -in private.pem -pubout -out public.pem 2. Add the public key to the domain
Paste public.pem into the domain's settings in the dashboard, or
PUT it via the API. We validate it (RSA, ≥2048-bit) and store only the public
key plus its fingerprint — never a private key.
curl -X PUT https://api.mailkite.dev/api/domains/DOMAIN_ID/encryption \
-H "authorization: Bearer mk_live_3a9f…" \
-H "content-type: application/json" \
-d "$(jq -Rs '{publicKey: .}' public.pem)"
# → { "domain": { …, "enc_key_alg": "RSA-OAEP-256", "enc_fingerprint": "a1b2…" } } What changes once it's on
- Your live webhook is unchanged — the first delivery is still plaintext over TLS. If you just consume webhooks, you do nothing.
- The stored copy of
textandhtmlis an encrypted envelope (below). The dashboard shows a locked state; replaying a message delivers the ciphertext for you to decrypt. subject,from,to, and timestamps stay plaintext so listings still work.- Attachments are inlined plaintext on the first delivery and not retained (so replays carry no attachments).
Is this hard to use? For the common case, no — if you process webhooks as they arrive, encryption is transparent (you get plaintext over TLS) and the only setup is pasting one public key. You only need the decrypt step below if you read the stored copy: replays, the Messages API, or the dashboard.
The stored envelope
When encryption is on, the text/html you read back from storage is this JSON, not the message:
{
"v": 1,
"keyAlg": "RSA-OAEP-256",
"fp": "a1b2c3…", // which key this was encrypted to
"enc": "A256GCM",
"iv": "…", // base64
"wrappedKey": "…", // base64: your public key wrapping the AES key
"ciphertext": "…" // base64: AES-256-GCM of the body
} Decrypting
Easiest path: every official SDK (0.4.0+) ships
encrypt(plaintext, publicKey) and
decrypt(envelope, privateKey) helpers — byte-compatible with the
at-rest envelope above. Pass the stored text/html
envelope and your private key; it returns plaintext. No API key, no network
call.
import { readFileSync } from "node:fs";
import { MailKite } from "mailkite";
const privateKey = readFileSync("private.pem", "utf8");
// The stored text/html is the encrypted envelope; decrypt() returns plaintext.
const text = MailKite.decrypt(message.text, privateKey);from mailkite import decrypt
with open("private.pem") as f:
private_key = f.read()
# The stored text/html is the encrypted envelope; decrypt() returns plaintext.
text = decrypt(message["text"], private_key)<?php
$mk = new \MailKite\Client(""); // no API key needed to decrypt
$privateKey = file_get_contents("private.pem");
// The stored text/html is the encrypted envelope; decrypt() returns plaintext.
$text = $mk->decrypt($message["text"], $privateKey);import dev.mailkite.MailKite;
import java.nio.file.*;
MailKite mk = new MailKite(""); // no API key needed to decrypt
String privateKey = Files.readString(Path.of("private.pem"));
// The stored text/html is the encrypted envelope; decrypt() returns plaintext.
String text = mk.decrypt(message.get("text"), privateKey);privateKey, _ := os.ReadFile("private.pem")
// The stored text/html is the encrypted envelope; Decrypt() returns plaintext.
text, err := mailkite.Decrypt(message["text"].(string), string(privateKey))require "mailkite"
private_key = File.read("private.pem")
# The stored text/html is the encrypted envelope; decrypt returns plaintext.
text = Mailkite.decrypt(message["text"], private_key) Not using an SDK? The envelope is a plain hybrid RSA-OAEP + AES-256-GCM format, so you can decrypt it with your private key using standard WebCrypto — no MailKite library required:
import { readFileSync } from "node:fs";
import { webcrypto as crypto } from "node:crypto";
// Load your RSA private key (the public half is what MailKite has).
const pem = readFileSync("private.pem", "utf8")
.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
const der = Uint8Array.from(atob(pem), (c) => c.charCodeAt(0));
const privateKey = await crypto.subtle.importKey(
"pkcs8", der, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"],
);
const b64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
export async function decrypt(envelopeJson) {
const e = JSON.parse(envelopeJson);
const rawKey = await crypto.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, b64(e.wrappedKey));
const key = await crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["decrypt"]);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(e.iv) }, key, b64(e.ciphertext));
return new TextDecoder().decode(pt);
}
// const text = await decrypt(message.text); // the value in 'text'/'html' is the envelope Encryption is a v1 of a hand-rolled hybrid envelope (RSA-OAEP wrapping AES-256-GCM). If you're storing regulated data, get your own crypto review before relying on it. Lose your private key and the encrypted mail is unrecoverable — that's the point of zero-knowledge.
Uptime SLA
Every paid plan is backed by a 99.9% monthly uptime SLA with service credits. Read the full terms at mailkite.dev/sla.
Which should I use?
| Mode | We store | We can read it | Replay |
|---|---|---|---|
| Default retention | Parsed message (auto-expires) | Yes | Yes |
| At-rest encryption | Encrypted body | No | Ciphertext |
| Zero-retention passthrough | Nothing | n/a | No |
See also Domains & DNS and Verifying signatures.