keymail API User docs →

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:

  1. Fetch the recipient's public keys (lookup): two raw uncompressed P-256 points, 65 bytes each, base64 (standard alphabet) — sign_pub and box_pub.
  2. 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.
  3. 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..."}.
  4. 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:

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:

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.

Newsletters

A newsletter is just an app whose grant carries scope newsletter: put "Subscribe with keymail" on your site, run the same flow, and each issue is one sealed kind: "newsletter" send per subscriber. Every subscriber's copy is sealed to their own key — there is no bulk endpoint, by design. Unsubscribing is the user revoking your grant, so honor 401s by dropping the subscription.

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.