The keymail API
Everything is JSON over HTTPS — including the OAuth endpoints. There are two ways to deliver mail to a keymail user: federation (open to anyone, priced with proof of work for strangers) and an app grant ("Sign in with keymail"): the user approves your app once, you get a bearer token that identifies them and lets you send without proof of work — revocable by the user at any moment.
One rule shapes all of it: the server only ever stores ciphertext. Whatever path a message takes, you seal it to the recipient's public key before it leaves your machine. There is no "send plaintext and we'll encrypt it" endpoint, and no endpoint that reads mail back to an app.
No registration, no API keys, no developer console. Your app's identity is its origin; the user's approval is the credential.
Sealing a message
keymail's envelope crypto is deliberately boring — four standard primitives, available in every language:
- Fetch the recipient's public keys (lookup):
two raw uncompressed P-256 points, 65 bytes each, base64
(standard alphabet) —
sign_pubandbox_pub. - Generate an ephemeral ECDH P-256 keypair. Compute the shared
secret with the recipient's
box_pub, then derive the message key:HKDF-SHA256(secret, salt=empty, info="keymail-envelope-v1")→ 32 bytes. - Encrypt the payload with AES-256-GCM, random 12-byte IV, no AAD.
The payload is a JSON object; for mail it's
{"subject": "...", "body": "...markdown..."}. - The blob is
ephemeralPublicKey(65) ‖ iv(12) ‖ ciphertext, base64 (standard).
Then sign. Your app has a long-lived ECDSA P-256 signing keypair
(generate it once, keep the private half); signatures are raw
r‖s, 64 bytes, base64, over SHA-256 of:
"keymail-env-v1\n" + id + "\n" + from + "\n" + to + "\n" + date + "\n" + blob
The finished envelope, used everywhere below:
{
"v": "keymail1",
"id": "<unique hex id, e.g. 32 hex chars>",
"from": "updates@app.example.com",
"to": "paul@keymail.dev",
"date": "2026-07-28T09:00:00Z",
"sign_pub": "<your app's public signing key, base64>",
"blob": "<sealed payload, base64>",
"sig": "<signature, base64>"
}
A reference implementation of both sides lives in the
open-source server (crypto.go) and browser
(web/static/crypto.js).
Discovery & lookup
GET /.well-known/keymail — instance info:
{"version":"keymail1","host":"keymail.dev","pow_bits":20}.
GET /api/federation/lookup?addr=paul@keymail.dev — the
recipient's public keys and your proof-of-work price:
{ "addr": "paul@keymail.dev",
"sign_pub": "…", "box_pub": "…",
"pow_bits": 20 }
404 = no such inbox; 409 = it exists but isn't claimed yet. With an
app grant you only need box_pub — the pow price doesn't
apply to you.
Sign in with keymail
OAuth 2.1-shaped: authorization code + PKCE (S256), with one twist
that removes registration — your client_id is your
origin (https://app.example.com; https required,
no path, http://localhost allowed for development). The
redirect_uri must be on that same origin. Scopes:
identify— learn the user's keymail address.send— deliver sealed messages to their Apps tab, no proof of work.newsletter— deliver to their Newsletters tab.
Send the user to:
https://keymail.dev/oauth/authorize
?client_id=https://app.example.com
&redirect_uri=https://app.example.com/callback
&scope=identify%20send
&state=<your CSRF token>
&code_challenge=<base64url(SHA-256(verifier))>
&code_challenge_method=S256
They see a consent card naming your origin and exactly what each scope
allows, with a chooser when this browser holds more than one inbox.
Allowing runs a passkey ceremony — on keymail.dev no code is minted
without a fresh gesture. On approval the browser returns to
redirect_uri?code=…&state=…; on refusal,
?error=access_denied. Codes are single-use and expire in
five minutes.
Token exchange
POST /api/oauth/token — JSON, not form-encoded (all of
keymail is JSON):
{ "code": "…",
"code_verifier": "…",
"client_id": "https://app.example.com",
"redirect_uri": "https://app.example.com/callback",
"sign_pub": "<your app's public signing key, base64>" }
sign_pub is required with send or
newsletter scope: it's pinned into the grant, and every
envelope you deliver must be signed with it. The response is the
sign-in:
{ "access_token": "…",
"token_type": "bearer",
"scope": "identify send",
"address": "paul@keymail.dev" }
Tokens don't expire; they die by revocation. Re-running the flow for the same user rotates the token (one grant per origin). Store it like a password — server-side, never in a client.
Who signed in
GET /api/app/profile with
Authorization: Bearer <access_token>:
{ "address": "paul@keymail.dev",
"client": "https://app.example.com",
"scope": "identify send" }
That's all an app can ever learn — an app token is useless against the owner's mailbox API.
Sending messages
Seal and sign an envelope (above), then:
POST /api/app/send
Authorization: Bearer <access_token>
{ "envelope": { …signed envelope… }, "kind": "app" }
Rules the server enforces:
tois the user who granted you — a grant authorizes exactly one recipient.fromis an address on your origin's host (anything@app.example.com) — an app writes as itself, never as someone the user might trust.sign_puband the signature match the key pinned at token exchange.
Response: {"status":"delivered","folder":"apps"}.
Redelivering the same envelope acks idempotently; reusing an id for
different content is a 409. Messages land in the user's
Apps tab (kind app, scope send) or
Newsletters tab (kind newsletter) — never their
inbox, unless the user chose to unify the tabs.
Revocation
The user can revoke any grant in Settings → Connected apps; from that instant your token 401s everywhere. Well-behaved apps retire their own token when the user disconnects on your side:
POST /api/oauth/revoke
{ "token": "<access_token>" }
Always answers 200. Re-authorization is always possible later — a revoked grant is dormant, not banned.
Federation & proof of work
Without a grant, you can still deliver — as any keymail instance does. First contact costs a hashcash proof of work bound to your signing key: request a challenge, mine, register, then deliver signed envelopes freely.
POST /api/federation/challenge {"to","from","sign_pub"} → {"challenge","bits","expires_at"}
POST /api/federation/register {"to","challenge","counter",
"from","sign_pub","box_pub",
"attestation"} → {"status":"proven"}
POST /api/federation/deliver {"envelope":{…}} → {"status":"delivered"}
The pow nonce is SHA-256(challenge-token); you search for
a big-endian 64-bit counter where
SHA-256(nonce ‖ sign_pub_raw ‖ counter) has
bits leading zero bits. Delivery to a stranger answers
403 {"error":"unknown_sender","pow_bits":N} — the price
list. Full details in the protocol doc in the source repo
(docs/protocol.md).
Errors
Every error is {"error": "lowercase prose"} with a
meaningful status: 400 bad request or failed PKCE, 401 bad or revoked
token, 403 the grant doesn't allow that (scope, recipient, from-host,
signing key), 404 no such mailbox, 409 id collision, 413 too big
(envelopes are capped at 256 KiB). Codes and tokens are minted per
mailbox and never logged; treat both as secrets.