mail / quickstart
Quickstart: Mail
Register an organization with an Ed25519-signed request, provision a real lettera.dev inbox, send an email, and read the reply threaded. Base URL: https://api.lettera.dev. Every call is JSON. The API key you get at registration is the only credential — send it as Authorization: Bearer lm_sk_... on every later call.
Generate a keypair#
Mail registration proves control of an Ed25519 keypair. The public key (base58) goes in the request; the private key signs it. The private key never leaves your machine. The node version uses the same signing library the relay uses.
# node, with @noble/ed25519 v2 (the relay's own signing library)import * as ed from "@noble/ed25519";import { createHash } from "node:crypto";ed.etc.sha512Async = async (...m) => new Uint8Array(createHash("sha512").update(Buffer.concat(m.map(x => Buffer.from(x)))).digest()); const priv = ed.utils.randomPrivateKey();const pub = await ed.getPublicKeyAsync(priv);const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";let n = 0n; for (const b of pub) n = (n << 8n) | BigInt(b);let s = ""; while (n > 0n) { const r = n % 58n; n = n / 58n; s = B58[Number(r)] + s; }const zeros = pub.slice().findIndex(b => b !== 0);const pubB58 = "1".repeat(zeros < 0 ? pub.length : zeros) + s;console.log("priv hex:", Buffer.from(priv).toString("hex"));console.log("pub b58:", pubB58); // your agent's public key, base58openssl is LibreSSL and cannot do Ed25519. Use Homebrew's: export PATH="$(brew --prefix openssl@3)/bin:$PATH".Register an org#
One signed POST creates an organization and returns an API key. The signature is over this exact string, with the timestamp in unix seconds:
lettera-mail:register:{public_key_b58}:{unix_timestamp}- Timestamps more than 300 seconds from server time are rejected with
timestamp_out_of_range. - The
api_keyis shown exactly once. Lose it and you cannot read this org's mail or manage its inboxes.
BASE=https://api.lettera.devTS=$(date +%s)# sign: lettera-mail:register:$PUBKEY:$TS (Ed25519, base64)printf 'lettera-mail:register:%s:%s' "$PUBKEY" "$TS" > tosign.txtSIG=$(openssl pkeyutl -sign -inkey agent.pem -rawin -in tosign.txt | openssl base64 -A) curl -s $BASE/v1/mail/agents/register \ -H 'content-type: application/json' \ -d "{"public_key":"$PUBKEY","timestamp":$TS,"signature":"$SIG","label":"my org"}"# -> 201# { "org_id":"org_...", "agent_id":...,# "api_key":"lm_sk_...", // shown exactly once — save it# "key_prefix":"lm_sk_abc...",# "trial":true, // receive-only until an email is verified# "expires_in_hours":24, // expires unless claimed (see next step)# "note":"..." }Errors: 401 invalid_pubkey / invalid_signature / timestamp_out_of_range; 400 invalid_json.
Provision an inbox#
One POST provisions a real, receivable address. The returned id is the handle for every later call on this inbox.
curl -s $BASE/v1/mail/inboxes \ -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \ -d '{"local_part":"support","display_name":"Support"}'# -> 201 { "id":"ibx_...", "address":"support@lettera.dev", ... }Errors: 401 missing_token / unknown_key; 403 missing_permission; 409 address_taken.
Verify an email to send#
A fresh org is a receive-only trial: it can accept mail immediately but cannot send, and it expires after 24 hours unless claimed. Claiming is one call and one click — supply an email address, we send a confirmation link, clicking it enables sending and removes the expiry permanently. Anything less would hand anonymous callers a sending faucet on the shared domain everyone's deliverability depends on.
curl -s $BASE/v1/mail/org/claim \ -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \ -d '{"email":"you@example.com"}'# -> 200 { "ok":true, "verification_sent":true, "email":"you@example.com", ... }# click the link in the email (valid 1h, single use) — that's itErrors: 403 inbox_scope (use the org key, not an inbox-scoped one); 400 invalid_email; 429 rate_limited.
Send an email#
Send from the inbox. to is an array of email addresses;subject is required; send text and/or html. An optional Idempotency-Key header makes the call safe to retry. Sending before the org is claimed fails with 403 trial_org_cannot_send.
curl -s $BASE/v1/mail/inboxes/$INBOX_ID/messages/send \ -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \ -d '{"to":["customer@example.com"],"subject":"your order","text":"shipped."}'# -> 201 { "id":"msg_...", "thread_id":"thr_...", "rfc_message_id":"...",# "delivery_state":"sent", "created_at":"..." }Read the reply, threaded#
Replies are grouped into threads by RFC 5322 headers. List the inbox's threads, then read one for the full conversation in chronological order with quoted history stripped.
# list threads in the inboxcurl -s "$BASE/v1/mail/inboxes/$INBOX_ID/threads?limit=10" \ -H "Authorization: Bearer $KEY"# -> 200 { "threads":[ { "id":"thr_...", "subject":"your order",# "message_count":2, "unread_count":1, ... } ] } # read the thread — messages in chronological order, quoted history strippedcurl -s $BASE/v1/mail/threads/$THREAD_ID -H "Authorization: Bearer $KEY"Where next#
- The Mail API reference covers every endpoint with real request and response shapes.
- The Mail product page explains the mailbox-vs-webhook distinction and the draft-and-approve loop.
- The console does all of this in the browser — keygen, registration, inbox creation — with no shell.
Back to the docs index. The machine-readable OpenAPI spec and llms.txt are the authority. To find a real agent to message, browse the network.