Lifetree Email

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
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>"
  }'
202 Accepted
{
  "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.

Header
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.

  1. Split the key. The public part is lt_<mode>_<id>; the secret is what follows the last underscore.
  2. Derive the signing key: signingKey = HMAC-SHA256(secret, "lifetree-l2-v1"), the raw 32 bytes.
  3. 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.
  4. Sign it: "v1=" + hex(HMAC-SHA256(signingKey, payload)).
  5. Send X-LT-Key, X-LT-Timestamp and X-LT-Signature, with no Authorization header.

The timestamp must be within 5 minutes of our clock, and a signature can be used only once.

Node
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.

FieldMeaning
fromRequired. { email, name? }, on a verified domain or a subdomain of one.
to, cc, bccArrays of { email, name? }. At least one recipient in all.
reply_toUp to 5 addresses.
subjectRequired, up to 998 bytes.
html, textAt 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.
attachmentsUp to 10. See Attachments.
headersOnly X-*, List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To, References.
priorityexpress, standard (the default) or batch.
send_atISO 8601 with offset, up to 7 days ahead. The message is cancellable until it goes.
window_minutesBatch lane: 30 to 1,440.
subscribedBatch lane: must be true. Your statement that these recipients opted in.
tagsUp to 5 short labels you can filter by.
metadataUp 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

LaneAccepted → SESCreditsRecipients
expressTarget 60 seconds, p99 under 2 minutes35
standardWithin 7 minutes150
batchSpread across your window1 (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
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
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.

JSON
"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.

POST to your URL
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.

Node
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:

StatusCodeMeaning
401unauthorizedNo valid key or session.
401signature_requiredThis key requires a signed request, or the signature does not match.
401timestamp_out_of_windowX-LT-Timestamp is over 5 minutes off. Check the machine's clock.
401replayed_requestThis exact signed request was already used.
402insufficient_creditsNot enough credits. Sending stops rather than billing you.
403scope_missingThis key lacks the scope the route needs.
403from_not_allowedThis key may not use that From domain.
403ip_not_allowedThe request came from outside the key's IP allowlist.
403recipient_not_verifiedSandbox or test key: the recipient has not confirmed.
403tenant_suspendedSending is paused on this account pending review.
403trust_html_not_allowedtrust_html needs a paid plan.
403step_up_requiredConsole only: confirm with a code first.
409idempotency_conflictThis Idempotency-Key was used with a different request.
409not_cancellableThe message has left the queue.
413too_largeOver 20 MB once encoded.
415multipart_requiredUpload attachments as multipart/form-data.
422invalid_requestA field is wrong; the message names it.
422domain_not_verifiedThe From domain is not verified on this account.
422invalid_fromThe From address is not valid.
422invalid_recipientA recipient address is not valid.
422recipient_domain_invalidA recipient's domain cannot receive mail.
422no_recipientsAdd at least one recipient.
422too_many_recipientsOver the lane's limit: express 5, others 50.
422recipient_suppressedEvery recipient is suppressed. The answer lists them.
422empty_bodySend html or text.
422invalid_subjectThe subject must be 1 to 998 bytes.
422header_not_allowedOnly X-*, List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To and References.
422attachment_rejectedType or contents are not allowed, or over 10 MB (then 413).
422attachment_not_foundThat attachment_id is unknown or past its 24 hours.
422send_at_too_farsend_at is over 7 days ahead.
422subscription_not_declaredThe batch lane needs subscribed: true.
422list_unsubscribe_requiredA batch message to several recipients needs its own List-Unsubscribe.
422role_addressabuse@, postmaster@, noreply@ and the like are refused on the batch lane.
422lane_not_allowedThe batch lane is not available through MCP.
429rate_limitedSlow down. retry_after says how many seconds.
429fanout_limitedToo 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.

TypeScript
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.

RecordWhy
3 CNAMEs at <token>._domainkey.example.comDKIM: mail is signed for your domain. Your sending reputation attaches to this.
MX at bounce.example.comBounces return to your own subdomain, which we read. It touches nothing on your root domain.
TXT at bounce.example.comSPF for that subdomain, aligned with your domain for DMARC. Your root SPF record is left alone.
TXT at _dmarc.example.comDMARC, 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.