MailVault
Live for early-access accounts

Receive mail as an encrypted webhook

Point an address at your own HTTPS endpoint. Each message is encrypted to your endpoint’s public key before it leaves our server, signed so you can prove it came from us, and not kept once it is delivered.

what we send
POST /mailvault HTTP/1.1
Host: hooks.example.com
Content-Type: application/octet-stream
X-MailVault-Format: json
X-MailVault-Encryption: age
X-MailVault-Signature: t=1788001200,k=2026-09,v1=kQ3v…
X-MailVault-Idempotency-Key: 9f2c41…e41a

age-encryption.org/v1
-> X25519 hV7p…
‹ 41 KB of ciphertext only your key opens ›
Nothing about the message travels in the clear — not the sender, not the subject, not even the recipient.
what your code readsafter verify and decrypt
{
  "recipient": "hi@example.com",
  "message": {
    "from": [{"address": "sender@corp.example"}],
    "subject": "quarterly numbers",
    "text_body": "Numbers attached.",
    "attachments": [{"filename": "q3.pdf", …}]
  },
  "authentication": {
    "action": "accept",
    "dmarc": {"result": "pass", "aligned": true}
  }
}
An excerpt. The full payload is in the reference.

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.

  1. It arrives

    A sending server delivers to our MX over TLS, like any other mail.

  2. We authenticate it

    SPF, DKIM and DMARC run on the message, and the verdict travels with the payload.

  3. We seal and sign it

    The payload is encrypted to your endpoint’s age key, then signed with our Ed25519 key.

  4. 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.

  1. 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
  2. Register your endpoint

    Give it a name you will refer to later. The format defaults to json; pass --format raw to 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
  3. 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:

    we send
    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 200 with the same token. A bare 200 is not enough: any URL on the internet returns one, and echoing the token is what shows your code read our request.

    you reply
    {"token":"<token>"}

    If your endpoint was not up when you registered it, run the check again once it is:

    $ mailvault endpoint verify prod
  4. 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 prod

    Send 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.

HeaderValue
Content-Typeapplication/octet-stream when encrypted; otherwise application/json or message/rfc822
X-MailVault-Formatjson or raw
X-MailVault-Encryptionage, or none for an endpoint registered with --no-encrypt
X-MailVault-Signaturet=<unix seconds>,k=<key id>,v1=<base64 Ed25519 signature>
X-MailVault-Idempotency-KeyHex SHA-256 of the recipient and the message as received. Deduplicate on this.
X-MailVault-DeliveryA fresh id for this attempt, for correlating logs. Never deduplicate on this.
User-AgentMailVault

Payload

With json, the whole object below is serialized and then encrypted, including the sender, the subject and the recipient.

format json
{
  "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.

crypto/ed25519, standard library only
// 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
}
node:crypto, no dependencies
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:

call only after verifySignature succeeds
// 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 replyWhat happens
2xxDelivered. Nothing further happens.
410 GonePermanent: the sending server gets a bounce. Use it only when the endpoint is retired for good.
Anything else, a timeout, or a connection errorTemporary: 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 on X-MailVault-Delivery.
  • Answer the verification challenge by echoing the token.
  • Reply 2xx quickly and do the work afterwards.
  • Reply 410 only for an endpoint retired for good.
  • Keep the age private key off the internet-facing machine if you can — verifying does not need it.