VaultMail
REST · SSE · v1

API reference

Free during MVP — no API keys, no rate limits, no auth. The Developer tier (with API keys, webhook delivery, custom domains, and higher rate limits) is in roadmap. Base URL:

https://vaultmail.viktorarsov.com
POST/api/v1/inboxes/generateGenerate an inbox (anonymous, free tier)
Request body
{
  "domain": "misterrium.bg",
  "local_part": "myalias"   // optional; random if omitted
}
Response
{
  "id": "0416d8e4-...",
  "address": "myalias@misterrium.bg",
  "message_count": 0,
  "unread_count": 0,
  "inbox_expires_at": "2026-05-08T11:30:00Z"
}
POST/api/v1/inboxesProgrammatic inbox (Developer tier — auth required)
Request body
{
  "domain": "misterrium.bg",
  "local_part": "checkout-tests",     // optional
  "label": "CI checkout regression",
  "ttl_seconds": 86400,                // null = persistent (paid)
  "webhook_url": "https://your.app/email-hook"
}
Response
{
  "id": "...",
  "address": "checkout-tests@misterrium.bg",
  "label": "CI checkout regression",
  "webhook_url": "https://your.app/email-hook",
  "webhook_secret": "<48 hex chars — shown ONCE, save it>"
}
Requires Authorization: Bearer <jwt> OR X-API-Key. Webhook payloads are HMAC-SHA256 signed using the secret returned here (header X-VaultMail-Signature).
GET/api/v1/domains/List domains
Response
{
  "domains": [
    { "name": "misterrium.bg",  "is_public": true, "tier_required": 0 },
    { "name": "tincar.bg",      "is_public": true, "tier_required": 0 },
    { "name": "barberscrew.bg", "is_public": true, "tier_required": 0 }
  ]
}
GET/api/v1/inboxes/{address}Get inbox + stats
Response
{ "id": "...", "address": "...", "message_count": 4, "unread_count": 1, ... }
Use /api/v1/inboxes/{address}/stats for aggregate counters (unread, OTPs, total bytes, attachments).
GET/api/v1/inboxes/{address}/messages?limit=50List messages
Response
[
  {
    "id": "ecbfa70e-...",
    "from_address": "noreply@spotify.com",
    "subject": "Confirm your account (code 482193)",
    "received_at": "...",
    "is_read": false,
    "has_otp": true,
    "raw_size": 533,
    "link_count": 1,
    "attachment_count": 0
  }
]
GET/api/v1/messages/{id}Read a message
Response
{
  "id": "...",
  "from_address": "noreply@spotify.com",
  "subject": "...",
  "body_text": "...",
  "body_html": "...",
  "links": [{ "index": 0, "url": "https://..." }],
  "attachments": [{ "filename": "...", "size": ... }],
  "otp_detected": "482193",
  "headers": { "From": "...", "DKIM-Signature": "..." }
}
Hitting this endpoint marks the message read.
GET/api/v1/messages/{id}/rawDownload raw .eml
Response
(application/rfc822 bytes; Content-Disposition: attachment)
GET/api/v1/messages/{id}/otpExtract OTP
Response
{
  "message_id": "...",
  "otp": "482193",
  "found": true,
  "subject": "Confirm your account (code 482193)"
}
If the message wasn't tagged at delivery time, this re-runs the extractor. Returns found=false when nothing matches.
GET/api/v1/messages/{id}/linksList links
Response
{
  "message_id": "...",
  "count": 2,
  "links": [
    { "index": 0, "url": "https://example.com/verify?token=abc" },
    { "index": 1, "url": "https://example.com/help" }
  ]
}
POST/api/v1/api-keysCreate API key
Request body
{ "label": "CI", "expires_in_days": 90 }
Response
{
  "id": "...",
  "key_prefix": "abc123def456",
  "label": "CI",
  "plaintext": "vlt_abc123def456_<48 hex secret>"
}
plaintext is shown ONCE. Use as X-API-Key header. Tier 3 (Developer) or higher required.
GET/api/v1/api-keysList my API keys
Response
[
  { "id": "...", "key_prefix": "abc...", "label": "CI",
    "is_active": true, "last_used_at": "...", "created_at": "..." }
]
DELETE/api/v1/api-keys/{id}Revoke API key
Response
204 No Content
GET/api/v1/sse/inbox/{address}Real-time stream (SSE)
Response
event: connected
data: {"address":"..."}

event: message
data: {"type":"new_message","id":"...","from":"...","subject":"...","has_otp":true}
Long-lived connection. Server emits 'connected' on subscribe, 'message' on every delivered email, and periodic 'ping' keepalives. Use EventSource on the browser, or a streaming HTTP client elsewhere.
POST/api/v1/inboxes/{address}/mark-all-readMark all as read
Response
204 No Content
POST/api/v1/inboxes/{address}/clearClear inbox
Response
204 No Content
Deletes every message. Address remains active.
DELETE/api/v1/messages/{id}Delete a message
Response
204 No Content

End-to-end example

# 1. Generate an inbox
ADDR=$(curl -s -X POST https://vaultmail.viktorarsov.com/api/v1/inboxes/generate \
  -H 'Content-Type: application/json' -d '{}' | jq -r .address)
echo "Inbox: $ADDR"

# 2. Watch for messages in another terminal
curl -N https://vaultmail.viktorarsov.com/api/v1/sse/inbox/$ADDR

# 3. Send a test email to that address (from any provider)

# 4. List messages
curl https://vaultmail.viktorarsov.com/api/v1/inboxes/$ADDR/messages

# 5. Read OTP
curl https://vaultmail.viktorarsov.com/api/v1/messages/<id>/otp | jq

Webhook delivery

When a message arrives at an inbox with webhook_url set, we POST a JSON payload to that URL. Up to 4 attempts (initial + 3 retries), backoff 5s → 25s → 125s. Verify X-VaultMail-Signature before trusting the payload.

Headers

X-VaultMail-Signature: sha256=<hex hmac of raw body, key = webhook_secret>
X-VaultMail-Event: message.received
X-VaultMail-Attempt: 1
Content-Type: application/json

Verify (Python)

import hmac, hashlib

def verify(secret: str, raw_body: bytes, signature_header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Verify (Node)

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, rawBody: Buffer, header: string) {
  const expected = "sha256=" + createHmac("sha256", secret)
    .update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}