API reference
Base URL: https://api.mailfade.dev
All endpoints return JSON unless noted otherwise. Errors use a stable shape:
{ "error": "machine_readable_code", "hint": "human readable context (optional)" }
Authentication
Most endpoints work without a key (free tier; rate-limited per IP per day). Paid endpoints — attachments, raw RFC822, HTML body, and any inbox bound to a custom domain — require:
Authorization: Bearer mf_qK3xJ7vQwzN8b9aP2rTfM5nL6yWkE0iU4hHsBdYcXvA
Keys are 46 characters: a mf_ prefix plus 43 base64url-safe characters
(32 bytes of entropy). They are returned exactly once at checkout,
embedded in the URL via a one-shot reveal_token. Store them in your CI
secrets — we cannot recover the plaintext if you lose it.
GET /inbox/:address
List the most recent messages in an inbox.
Path params
| Name | Type | Required | Description |
|---|---|---|---|
address | string | yes | Full address, e.g. [email protected]. URL-encode if necessary. |
Query params
| Name | Type | Default | Description |
|---|---|---|---|
limit | int | 25 | Max items to return (capped at 50). |
since | int | — | Unix ms — only return messages received after this timestamp. |
Response
{
"address": "[email protected]",
"count": 1,
"emails": [
{
"id": "01HF5K9X2E4...",
"sender": "[email protected]",
"subject": "Confirm your account",
"has_attachments": false,
"size_bytes": 4821,
"received_at": 1716729384213,
"expires_at": 1716733000000
}
]
}
GET /message/:id
Get a single message. Without a key, only the plain-text body is included. With a key, the rendered HTML body and full headers come back too.
Query params
| Name | Default | Description |
|---|---|---|
html | 1 | Set to 0 to skip the HTML fetch (saves one R2 round trip). |
Response
{
"id": "01HF5K9X2E4...",
"address": "[email protected]",
"sender": "[email protected]",
"to": ["[email protected]"],
"subject": "Confirm your account",
"text": "Click here to confirm: https://...",
"html": "<html>...</html>",
"attachments": [],
"size_bytes": 4821,
"received_at": 1716729384213,
"expires_at": 1716733000000
}
GET /message/:id/raw
Stream the raw RFC822 message as message/rfc822. Requires a key.
GET /attachment/:id
Stream a single attachment. Requires a key. Content-Type and Content-Disposition are set from the original message.
POST /keys
Create a checkout session for a Dev or Team plan.
Body
{ "plan": "dev", "rail": "stripe" }
rail accepts "stripe" (card subscription) or "lightning" (one-time
BTCPay invoice). Returns:
{
"invoice_id": "01HF5...",
"rail": "stripe",
"checkout_url": "https://checkout.stripe.com/..."
}
Redirect the user (or open it in a browser); the key is returned once on success, and you can also poll status with the next endpoint.
GET /keys/:invoiceId
Poll the status of a checkout session. Returns "status": "pending" until
the webhook fires; then returns "status": "paid" along with key_id.
To retrieve the plaintext key, pass the reveal_token that was embedded
in your success_url as either ?t=<token> or the x-reveal-token
header. Successful reveals return:
{
"invoice_id": "01HF…",
"status": "paid",
"rail": "stripe",
"plan": "dev",
"paid_at": 1716729384213,
"key_id": "01HG…",
"plaintext_key": "mf_qK3xJ7vQwzN8b9aP2rTfM5nL6yWkE0iU4hHsBdYcXvA",
"hint": "Save this key now — it will not be shown again."
}
The reveal is one-shot: a second poll (or a poll without the token)
returns plaintext_key: null and a hint explaining what happened.
POST /keys/me/revoke
Revoke the bearer key sent in Authorization. Idempotent.
POST /keys/me/rotate
Atomically: issue a new key on the same plan, carry remaining quota forward, revoke the old key, return the new plaintext exactly once. Use after a suspected leak (GitHub commit, screenshot).
Status codes
| Code | Meaning |
|---|---|
| 200 | OK |
| 400 | Bad request — invalid_address, invalid_plan, invalid_json |
| 401 | missing_or_invalid_key, key_required |
| 402 | key_inactive — response body: {error, status: 'revoked' | 'expired'} |
| 404 | not_found |
| 429 | free_tier_quota_exhausted, quota_exhausted, checkout_rate_limited |
| 503 | stripe_unavailable / lightning_unavailable — rail not configured; or auth_unavailable — service misconfigured |
| 500 | internal_error, upstream_timeout, db_unavailable, storage_unavailable |
Every response includes an x-request-id header (ULID); include it when
you write to support so we can find the corresponding log line.
Rate limits
Free tier: 100 requests per IP per UTC day across /inbox and /message.
Paid keys: monthly request budget by plan; resets at period_resets_at.
Both buckets return helpful headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87