How a message travels
Delivery happens while the sending server is still connected. There is no queue on our side, so there is nothing of yours sitting anywhere waiting to be sent.
-
It arrives
A sending server delivers to our MX over TLS, like any other mail.
-
We authenticate it
SPF, DKIM and DMARC run on the message, and the verdict travels with the payload.
-
We seal and sign it
The payload is encrypted to your endpoint’s age key, then signed with our Ed25519 key.
-
We post it once
If your endpoint does not accept it, the sending server is told to retry later.
Quick-start
You need an early-access account with the mailvault CLI signed in, a verified domain, and an HTTPS URL you control.
-
Create a key pair
The private key stays with you. We only ever see the public half, and it is all we need to encrypt to you.
$ age-keygen -o endpoint.key Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p -
Register your endpoint
Give it a name you will refer to later. The format defaults to
json; pass--format rawto receive the original message bytes instead.$ mailvault endpoint add prod \ --url https://hooks.example.com/mailvault \ --key age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # sends the verification challenge below to your URL straight away -
Answer the verification challenge
An endpoint receives no mail until it proves it wants mail. We post this, signed like any delivery but never encrypted, since you have to read it to answer it:
POST https://hooks.example.com/mailvault Content-Type: application/json X-MailVault-Encryption: none X-MailVault-Signature: t=…,k=…,v1=… {"type":"endpoint.verify","token":"<token>","endpoint":"prod"}Reply
200with the same token. A bare200is not enough: any URL on the internet returns one, and echoing the token is what shows your code read our request.{"token":"<token>"}If your endpoint was not up when you registered it, run the check again once it is:
$ mailvault endpoint verify prod -
Point an address at it
An address can post to an endpoint, forward to a mailbox, or both.
$ mailvault addr add hi@example.com --endpoint prod # or every address on the domain $ mailvault addr add @example.com --endpoint prodSend a message to
hi@example.com. It reaches your endpoint as a POST while the sending server is still connected.
Receiver reference
Everything the code behind your URL needs to handle a delivery correctly.
What we keep
The payload is encrypted to your key before it leaves our server, and nothing is kept after delivery. That is the whole promise, and it is deliberately no bigger: authenticating a message means reading it, so it exists in plaintext inside our process for the moments that takes.
It is never written to disk, logged, queued or copied, and we keep no history of what we delivered.
Headers
One POST per recipient, per endpoint, per accepted message.
| Header | Value |
|---|---|
Content-Type | application/octet-stream when encrypted; otherwise application/json or message/rfc822 |
X-MailVault-Format | json or raw |
X-MailVault-Encryption | age, or none for an endpoint registered with --no-encrypt |
X-MailVault-Signature | t=<unix seconds>,k=<key id>,v1=<base64 Ed25519 signature> |
X-MailVault-Idempotency-Key | Hex SHA-256 of the recipient and the message as received. Deduplicate on this. |
X-MailVault-Delivery | A fresh id for this attempt, for correlating logs. Never deduplicate on this. |
User-Agent | MailVault |
Payload
With json, the whole object below is serialized and then encrypted, including the sender, the subject and the recipient.
{
"delivery_id": "8f14e45f-ceea-167a-5a36-dedd4bea2543",
"received_at": "2026-09-11T12:00:00Z",
"recipient": "hi@example.com",
"via_catch_all": false,
"envelope": {
"mail_from": "sender@corp.example",
"helo": "mail.corp.example",
"remote_ip": "203.0.113.10"
},
"message": {
"message_id": "<abc@corp.example>",
"from": [{"name": "A Sender", "address": "sender@corp.example"}],
"to": [{"name": "", "address": "hi@example.com"}],
"cc": [],
"reply_to": [],
"subject": "quarterly numbers",
"date": "Thu, 11 Sep 2026 12:00:00 +0000",
"text_body": "…",
"html_body": "…",
"headers": {"Subject": ["quarterly numbers"]},
"attachments": [{
"filename": "q3.pdf",
"content_type": "application/pdf",
"size": 1234,
"content": "<base64>"
}]
},
"authentication": {
"action": "accept",
"spf": {"result": "pass", "domain": "corp.example"},
"dkim": {"result": "pass", "domain": "corp.example", "selector": ""},
"dmarc": {"result": "pass", "policy": "reject", "aligned": true}
}
}
A message we could not fully parse is still delivered, with the fields we could not fill left empty. When a message offers several renditions of its body, you get the one the sender prefers.
dkim.selector is always empty; use dkim.domain, which names the signature that verified. dmarc.aligned is true when SPF or DKIM aligned, which is all DMARC requires.
With raw, you get the message bytes exactly as they arrived with one Authentication-Results header prepended and nothing else, so you can re-verify the sender’s DKIM signature yourself.
Verify the signature
Verify before you decrypt. We sign the bytes exactly as posted — the ciphertext — so a forged body is rejected before it ever reaches your private key. Read the raw body; a framework that parses and re-serializes it will break the signature.
The signed bytes are the timestamp, a dot, then the body. Reject a timestamp more than five minutes from now in either direction. Fetch our public keys from:
$ curl -s https://api.mailvault.sh/.well-known/mailvault-webhook-keys | jq .
{
"keys": [
{
"id": "2026-09",
"alg": "ed25519",
"public_key": "FpxNXKVZ0I40sEUZQVqbHuLuoVuizBGMKHAawzSdzvE="
}
]
}
Match the k= in the signature against id. Cache the set, and fetch it again when you meet an id you do not know — that is all a key rotation asks of you.
// verifySignature checks X-MailVault-Signature against the body exactly as
// posted. keys maps a key id to its Ed25519 public key.
func verifySignature(
header string, body []byte, keys map[string]ed25519.PublicKey, now time.Time,
) error {
var ts, id, sig string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
return errors.New("malformed signature header")
}
switch k {
case "t":
ts = v
case "k":
id = v
case "v1":
sig = v
}
}
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil || id == "" || sig == "" {
return errors.New("malformed signature header")
}
if d := now.Sub(time.Unix(unix, 0)); d > 5*time.Minute || d < -5*time.Minute {
return errors.New("signature outside the five-minute window")
}
pub, ok := keys[id]
if !ok {
return fmt.Errorf("unknown key id %q: refetch the key set", id)
}
raw, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
return errors.New("malformed signature")
}
if !ed25519.Verify(pub, append([]byte(ts+"."), body...), raw) {
return errors.New("signature does not verify")
}
return nil
}
import crypto from "node:crypto";
// Keys are published as 32 raw Ed25519 bytes. Node wants SPKI DER, which is
// a fixed 12-byte prefix followed by those bytes.
function publicKeyFromBase64(b64) {
return crypto.createPublicKey({
key: Buffer.concat([
Buffer.from("302a300506032b6570032100", "hex"),
Buffer.from(b64, "base64"),
]),
format: "der",
type: "spki",
});
}
// verifySignature throws unless the header is well formed, inside the
// five-minute window in either direction, and verifies against its key.
// body must be the raw request body as a Buffer.
function verifySignature(header, body, keys, now = Date.now()) {
const parts = {};
for (const part of String(header ?? "").split(",")) {
const i = part.indexOf("=");
if (i > 0) parts[part.slice(0, i).trim()] = part.slice(i + 1);
}
const { t, k, v1 } = parts;
if (!t || !k || !v1) throw new Error("malformed signature header");
const skew = now - Number(t) * 1000;
if (!Number.isFinite(skew) || Math.abs(skew) > 5 * 60 * 1000) {
throw new Error("signature outside the five-minute window");
}
const key = keys.get(k);
if (!key) throw new Error(`unknown key id ${k}: refetch the key set`);
const signed = Buffer.concat([Buffer.from(`${t}.`), body]);
if (!crypto.verify(null, signed, key, Buffer.from(v1, "base64"))) {
throw new Error("signature does not verify");
}
}
The challenge from the quick-start is signed with the same key and header, so the same function verifies it. Answer it only after verification passes, like everything else.
Decrypt
Bodies are binary age, encrypted to the X25519 key you registered. From a shell, with a body saved to a file:
$ age -d -i endpoint.key < body.age | jq .
In Go, with filippo.io/age:
// decrypt turns a verified body into the plaintext payload.
func decrypt(identity io.Reader, body []byte) ([]byte, error) {
ids, err := age.ParseIdentities(identity)
if err != nil {
return nil, err
}
r, err := age.Decrypt(bytes.NewReader(body), ids...)
if err != nil {
return nil, err
}
return io.ReadAll(r)
}
In Node, use the age-encryption package or call the age binary.
Endpoints that cannot hold a key. Registering with --no-encrypt posts the payload in the clear with X-MailVault-Encryption: none. It is still signed, but signed is not private: anything that can see the request can read the mail.
Duplicates
You will sometimes receive the same message twice. Because nothing queues on our side, a failure tells the sending server to retry, and it retransmits the whole message. If one message goes to two endpoints and only one accepts it, the one that accepted receives it again on the retry.
That is deliberate: a duplicate is visible and recoverable, and a silently dropped message is neither. Deduplicate on X-MailVault-Idempotency-Key, which stays the same across a sender’s retries and differs per recipient. X-MailVault-Delivery changes on every attempt, so deduplicating on it removes nothing.
Status codes
| You reply | What happens |
|---|---|
2xx | Delivered. Nothing further happens. |
410 Gone | Permanent: the sending server gets a bounce. Use it only when the endpoint is retired for good. |
| Anything else, a timeout, or a connection error | Temporary: the sending server is told to retry later. |
Reply as soon as you have safely stored the payload and do the real work afterwards: each POST waits at most ten seconds, while the sending server is on the line. Redirects are not followed, and a failing endpoint is never switched off for you — its health shows in mailvault endpoint list.
Catch-alls
A catch-all, written as @example.com, delivers to endpoints only: mailvault addr add @example.com --endpoint prod. It cannot forward to a mailbox, because it accepts every local part anyone tries and relaying those would spend the sending reputation your aliases rely on.
It posts every one of those local parts, including every guess in a dictionary attack on your domain. Filter on authentication.action and the DMARC result before acting on what arrives.
Checklist
- Read the raw body; do not let a framework re-serialize it first.
- Verify the signature before decrypting or touching the payload.
- Enforce the five-minute window in both directions.
- Fetch the key set again when you see an unknown key id.
- Deduplicate on
X-MailVault-Idempotency-Key, never onX-MailVault-Delivery. - Answer the verification challenge by echoing the token.
- Reply
2xxquickly and do the work afterwards. - Reply
410only for an endpoint retired for good. - Keep the age private key off the internet-facing machine if you can — verifying does not need it.