Docs
The API lives at https://api.lifetree.email. Requests and answers are JSON. Every error is { code, message }, and code is what to switch on.
Quickstart
Add and verify a domain in the console, create a key, then:
curl https://api.lifetree.email/v1/messages \
-H "Authorization: Bearer $LIFETREE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"from": { "email": "hello@example.com", "name": "Example" },
"to": [{ "email": "dana@acme.example" }],
"subject": "Your invoice is ready",
"html": "<p>Invoice 1042 is ready.</p>"
}'{
"id": "msg_01j9x4",
"status": "queued",
"lane": "standard",
"eta": "2026-11-01T12:07:00.000Z",
"credits_charged": 1
}Authentication
A key looks like lt_live_<16 hex id>_<64 hex secret>, or lt_test_ for a test key, which only sends to the SES simulator and addresses you have verified. We store only a hash of the secret. Keys carry scopes: send, read, domains, webhooks, admin.
L1: Bearer
The simplest way, and enough for most servers.
Authorization: Bearer lt_live_0123456789abcdef_<secret>L2: Signed requests
The secret never travels, and a captured request cannot be changed, replayed, or pointed at another route. A key created with Require signature accepts nothing else.
- Split the key. The public part is
lt_<mode>_<id>; the secret is what follows the last underscore. - Derive the signing key:
signingKey = HMAC-SHA256(secret, "lifetree-l2-v1"), the raw 32 bytes. - Build the payload:
timestamp + "." + METHOD + "." + pathWithQuery + "." + sha256hex(body). The timestamp is unix seconds; the method is uppercase; the body is the exact bytes you send, and empty for a GET. - Sign it:
"v1=" + hex(HMAC-SHA256(signingKey, payload)). - Send
X-LT-Key,X-LT-TimestampandX-LT-Signature, with no Authorization header.
The timestamp must be within 5 minutes of our clock, and a signature can be used only once.
import { createHash, createHmac } from "node:crypto";
const [, mode, id, secret] = /^lt_(live|test)_([0-9a-f]{16})_([0-9a-f]{64})$/.exec(process.env.LIFETREE_API_KEY);
const signingKey = createHmac("sha256", secret).update("lifetree-l2-v1").digest();
const path = "/v1/messages";
const body = JSON.stringify({
from: { email: "hello@example.com" },
to: [{ email: "dana@acme.example" }],
subject: "Hi",
text: "Hello",
});
const timestamp = String(Math.floor(Date.now() / 1000));
const bodyHash = createHash("sha256").update(body).digest("hex");
const payload = `${timestamp}.POST.${path}.${bodyHash}`;
const signature = "v1=" + createHmac("sha256", signingKey).update(payload, "utf8").digest("hex");
await fetch("https://api.lifetree.email" + path, {
method: "POST",
headers: {
"content-type": "application/json",
"x-lt-key": `lt_${mode}_${id}`,
"x-lt-timestamp": timestamp,
"x-lt-signature": signature,
},
body,
});Send a message
POST /v1/messages, scope send. Answers 202 once the message is safely queued.
| Field | Meaning |
|---|---|
from | Required. { email, name? }, on a verified domain or a subdomain of one. |
to, cc, bcc | Arrays of { email, name? }. At least one recipient in all. |
reply_to | Up to 5 addresses. |
subject | Required, up to 998 bytes. |
html, text | At least one. Text is generated from HTML when you leave it out. HTML is cleaned against an allowlist and styled inline, unless a paid plan sets trust_html. |
attachments | Up to 10. See Attachments. |
headers | Only X-*, List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To, References. |
priority | express, standard (the default) or batch. |
send_at | ISO 8601 with offset, up to 7 days ahead. The message is cancellable until it goes. |
window_minutes | Batch lane: 30 to 1,440. |
subscribed | Batch lane: must be true. Your statement that these recipients opted in. |
tags | Up to 5 short labels you can filter by. |
metadata | Up to 1 KB of JSON, returned on webhooks. |
Send an Idempotency-Key header (1 to 200 letters, digits or _.:-). For 24 hours the same key and body return the first answer, with Idempotent-Replayed: true, and never send twice. If some recipients are suppressed, they are skipped and listed in suppressed. A standard message whose content reached over 200 recipients in an hour moves to batch, and lane_changed says so.
Lanes and credits
| Lane | Accepted → SES | Credits | Recipients |
|---|---|---|---|
| express | Target 60 seconds, p99 under 2 minutes | 3 | 5 |
| standard | Within 7 minutes | 1 | 50 |
| batch | Spread across your window | 1 (0.7 on Scale) | 50 |
Credits are charged per recipient, to, cc and bcc alike. Attachments are free up to 1 MB per message, then one credit for each started MB. Cancelled messages and permanent SES refusals are refunded; bounces and complaints are not. The clock stops when SES accepts the message: we report when the receiving server took it, but do not promise that time.
Batch
POST /v1/messages/batch takes up to 100 messages, spreads them across the window, and answers 207: each message is accepted or refused on its own. With an Idempotency-Key, each message gets its own derived key.
curl https://api.lifetree.email/v1/messages/batch \
-H "Authorization: Bearer $LIFETREE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"window_minutes": 60,
"messages": [
{
"from": { "email": "news@example.com" },
"to": [{ "email": "a@acme.example" }],
"subject": "October update",
"html": "<p>Hello</p>",
"subscribed": true
}
]
}'With one recipient we add a working one-click List-Unsubscribe for you. With several, bring your own. Role addresses such as abuse@ are refused, and a batch over 5,000 needs an account at least 14 days old.
Attachments
One file up to 10 MB, 10 per message, 20 MB per message once encoded. Executables, scripts and macro documents are refused, and contents are checked against the file's type. Two ways to send one:
Upload, then reference
Multipart, with the field named file. The id lasts 24 hours.
curl https://api.lifetree.email/v1/attachments \
-H "Authorization: Bearer $LIFETREE_API_KEY" \
-F file=@invoice.pdf
# { "id": "att_01j9x5", "filename": "invoice.pdf", "content_type": "application/pdf",
# "size_bytes": 48211, "expires_at": "..." }
# then, in POST /v1/messages:
"attachments": [{ "attachment_id": "att_01j9x5" }]Inline in the JSON
Base64 in the request, for small files. Encoding adds about 37%, and a request can be at most about 12 MB.
"attachments": [{ "filename": "invoice.pdf", "content_type": "application/pdf", "content": "JVBERi0xLjcK..." }]Status and events
GET /v1/messages/:id gives status, recipients, the event timeline, the SES id and whether the SLO was met. GET /v1/messages filters by status, tag, recipient, since, until, with limit and cursor. POST /v1/messages/:id/cancel works while queued or scheduled and returns the credits.
Statuses: queued, scheduled, sending, sent (SES accepted it), delivered (the receiving server accepted it, which is not the same as reaching the inbox), bounced, complained, delayed, failed, cancelled.
Webhooks
Create them in the console or with POST /v1/webhooks. The secret (whsec_...) is shown once. Events: message.queued, .sending, .sent, .delivered, .bounced, .complained, .delayed, .failed, .cancelled, .opened, .clicked.
X-LT-Timestamp: 1793534400
X-LT-Signature: v1=5f2c...
X-LT-Delivery: whd_01j9x6
{ "id": "whd_01j9x6", "type": "message.delivered", "created_at": "...", "data": { "message_id": "msg_01j9x4" } }Answer with any 2xx. Anything else retries after 1, 5, 30, 120 and 720 minutes, and 24 hours of failure turns the endpoint off. Every delivery can be seen and replayed from the console.
Verifying the signature
signature = "v1=" + hex(HMAC-SHA256(secret, timestamp + "." + sha256hex(rawBody))). Unlike L2 requests, the secret is used directly, with no derived key. Use the raw bytes you received, before any JSON parsing, and refuse a timestamp over 5 minutes old.
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, timestamp, signature, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const bodyHash = createHash("sha256").update(rawBody).digest("hex");
const expected = Buffer.from("v1=" + createHmac("sha256", secret).update(`${timestamp}.${bodyHash}`, "utf8").digest("hex"));
const given = Buffer.from(signature);
return expected.length === given.length && timingSafeEqual(expected, given);
}Errors
Refusals are synchronous 4xx answers. Those with retry_after say when to try again. The common ones:
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | No valid key or session. |
| 401 | signature_required | This key requires a signed request, or the signature does not match. |
| 401 | timestamp_out_of_window | X-LT-Timestamp is over 5 minutes off. Check the machine's clock. |
| 401 | replayed_request | This exact signed request was already used. |
| 402 | insufficient_credits | Not enough credits. Sending stops rather than billing you. |
| 403 | scope_missing | This key lacks the scope the route needs. |
| 403 | from_not_allowed | This key may not use that From domain. |
| 403 | ip_not_allowed | The request came from outside the key's IP allowlist. |
| 403 | recipient_not_verified | Sandbox or test key: the recipient has not confirmed. |
| 403 | tenant_suspended | Sending is paused on this account pending review. |
| 403 | trust_html_not_allowed | trust_html needs a paid plan. |
| 403 | step_up_required | Console only: confirm with a code first. |
| 409 | idempotency_conflict | This Idempotency-Key was used with a different request. |
| 409 | not_cancellable | The message has left the queue. |
| 413 | too_large | Over 20 MB once encoded. |
| 415 | multipart_required | Upload attachments as multipart/form-data. |
| 422 | invalid_request | A field is wrong; the message names it. |
| 422 | domain_not_verified | The From domain is not verified on this account. |
| 422 | invalid_from | The From address is not valid. |
| 422 | invalid_recipient | A recipient address is not valid. |
| 422 | recipient_domain_invalid | A recipient's domain cannot receive mail. |
| 422 | no_recipients | Add at least one recipient. |
| 422 | too_many_recipients | Over the lane's limit: express 5, others 50. |
| 422 | recipient_suppressed | Every recipient is suppressed. The answer lists them. |
| 422 | empty_body | Send html or text. |
| 422 | invalid_subject | The subject must be 1 to 998 bytes. |
| 422 | header_not_allowed | Only X-*, List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To and References. |
| 422 | attachment_rejected | Type or contents are not allowed, or over 10 MB (then 413). |
| 422 | attachment_not_found | That attachment_id is unknown or past its 24 hours. |
| 422 | send_at_too_far | send_at is over 7 days ahead. |
| 422 | subscription_not_declared | The batch lane needs subscribed: true. |
| 422 | list_unsubscribe_required | A batch message to several recipients needs its own List-Unsubscribe. |
| 422 | role_address | abuse@, postmaster@, noreply@ and the like are refused on the batch lane. |
| 422 | lane_not_allowed | The batch lane is not available through MCP. |
| 429 | rate_limited | Slow down. retry_after says how many seconds. |
| 429 | fanout_limited | Too many copies of the same content on the express lane this hour. |
Node SDK
@lifetree/email needs Node 18 or later and has no dependencies. It signs, retries, and generates an idempotency key when you give none, so its own retries can never send twice.
import { Lifetree, LifetreeError } from "@lifetree/email";
// sign: true sends L2 signatures rather than the key.
const client = new Lifetree({ apiKey: process.env.LIFETREE_API_KEY!, sign: true });
try {
const { id, lane, eta } = await client.messages.send({
from: { email: "hello@example.com" },
to: [{ email: "dana@acme.example" }],
subject: "Your invoice is ready",
html: "<p>Invoice 1042 is ready.</p>",
priority: "standard",
});
const message = await client.messages.get(id);
console.log(message.status, lane, eta);
} catch (error) {
if (error instanceof LifetreeError && error.code === "insufficient_credits") {
// Top up in the console.
} else {
throw error;
}
}It also has client.messages.sendBatch, .list, .cancel, client.account.usage, and a verifyWebhook helper.
DNS explained
Six records, and none of them is an MX on your own domain, so whoever receives your mail today carries on receiving it.
| Record | Why |
|---|---|
3 CNAMEs at <token>._domainkey.example.com | DKIM: mail is signed for your domain. Your sending reputation attaches to this. |
MX at bounce.example.com | Bounces return to your own subdomain, which we read. It touches nothing on your root domain. |
TXT at bounce.example.com | SPF for that subdomain, aligned with your domain for DMARC. Your root SPF record is left alone. |
TXT at _dmarc.example.com | DMARC, starting at p=none, which changes nothing about delivery. Reports come to us and show on the domain's page. If you already have a DMARC record, keep it. |
The domain can send as soon as SES reports both DKIM and MAIL FROM as successful, usually minutes after the records are visible. On Cloudflare, keep the CNAMEs set to DNS only; the token path does this for you. Some DNS panels add your domain to the name for you, so enter just the part before it if the record ends up doubled.
OpenAPI
The machine-readable description is at /openapi.yaml.