openapi: 3.1.0
info:
  title: Lifetree Email API
  version: "1"
  description: |
    Send email through your own domain. Promise: every standard message is handed to Amazon SES within 7 minutes of acceptance.

    Errors are JSON `{ "code": "...", "message": "..." }`. Switch on `code`.
servers:
  - url: https://api.lifetree.email
security:
  - bearer: []
  - signed: []
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: "L1. Authorization: Bearer lt_live_<id>_<secret>"
    signed:
      type: apiKey
      in: header
      name: X-LT-Key
      description: |
        L2. The secret never travels. Send `X-LT-Key: lt_live_<id>`, `X-LT-Timestamp: <unix seconds>` and
        `X-LT-Signature: v1=<hex>`, where

            signing_key = HMAC-SHA256(secret, "lifetree-l2-v1")
            signature   = HMAC-SHA256(signing_key, timestamp "." METHOD "." path-with-query "." sha256hex(body))

        The window is five minutes and a signature works once.
  parameters:
    Idempotency:
      name: Idempotency-Key
      in: header
      required: false
      description: 24 hours. Same key and same body returns the first answer; same key and a different body is a 409. Required for internal tenants.
      schema: { type: string, maxLength: 200, pattern: "^[A-Za-z0-9_.:-]+$" }
    Id:
      name: id
      in: path
      required: true
      schema: { type: string }
  responses:
    Error:
      description: A refusal.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          examples: [domain_not_verified, recipient_suppressed, insufficient_credits, too_large, header_not_allowed, rate_limited, daily_cap_reached, tenant_suspended, signature_required, replayed_request, step_up_required]
        message: { type: string }
    Mailbox:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        name: { type: string, maxLength: 200 }
    Lane:
      type: string
      enum: [express, standard, batch]
      description: "express: about 60 seconds, 3 credits a recipient, up to 5 recipients. standard: within 7 minutes, 1 credit, up to 50. batch: spread over a window, 1 credit, needs subscribed and List-Unsubscribe."
    Attachment:
      type: object
      properties:
        filename: { type: string }
        content_type: { type: string }
        content: { type: string, description: Base64. Give this or attachment_id. }
        attachment_id: { type: string }
        disposition: { type: string, enum: [attachment, inline], default: attachment }
        content_id: { type: string }
    SendRequest:
      type: object
      required: [from, subject]
      properties:
        from: { $ref: "#/components/schemas/Mailbox" }
        to: { type: array, items: { $ref: "#/components/schemas/Mailbox" } }
        cc: { type: array, items: { $ref: "#/components/schemas/Mailbox" } }
        bcc: { type: array, items: { $ref: "#/components/schemas/Mailbox" } }
        reply_to: { type: array, items: { type: string }, maxItems: 5 }
        subject: { type: string, description: Up to 998 bytes. }
        html: { type: string, description: "Up to 512 KB. Cleaned by an allowlist, then laid out for mail clients." }
        text: { type: string, description: Generated from html when absent. }
        trust_html: { type: boolean, description: "Paid plans: send your HTML as written, with only scripts and handlers removed." }
        attachments: { type: array, maxItems: 10, items: { $ref: "#/components/schemas/Attachment" } }
        headers:
          type: object
          additionalProperties: { type: string }
          description: "Allowed: X-* (not X-SES-*), In-Reply-To, References, List-Unsubscribe, List-Unsubscribe-Post."
        priority: { $ref: "#/components/schemas/Lane" }
        send_at: { type: string, format: date-time, description: Up to 7 days ahead. }
        window_minutes: { type: integer, minimum: 30, maximum: 1440, description: Batch lane only. }
        subscribed: { type: boolean, description: "Batch lane: your statement that the recipients opted in." }
        tags: { type: array, maxItems: 5, items: { type: string, maxLength: 40 } }
        metadata: { type: object, description: Returned on webhooks. }
    SendResult:
      type: object
      required: [id, status, lane, eta, credits_charged]
      properties:
        id: { type: string }
        status: { type: string, enum: [queued, scheduled] }
        lane: { $ref: "#/components/schemas/Lane" }
        eta: { type: string, format: date-time }
        credits_charged: { type: number }
        lane_changed:
          type: object
          description: The same content went to too many people this hour, so it moved to the batch lane.
          properties:
            from: { type: string }
            reason: { type: string }
        suppressed: { type: array, items: { type: string }, description: Recipients removed because they are suppressed. }
    Message:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [queued, scheduled, sending, sent, delivered, bounced, complained, delayed, failed, cancelled] }
        lane: { $ref: "#/components/schemas/Lane" }
        from: { $ref: "#/components/schemas/Mailbox" }
        subject: { type: string }
        accepted_at: { type: string, format: date-time }
        sent_at: { type: [string, "null"], format: date-time, description: SES took it. The 7-minute promise is measured to here. }
        ses_message_id: { type: [string, "null"] }
        credits_charged: { type: number }
        recipient_count: { type: integer }
        slo_met: { type: [boolean, "null"] }
        slo_ms: { type: [integer, "null"] }
        recipients: { type: array, items: { type: object } }
        events: { type: array, items: { type: object } }
paths:
  /v1/messages:
    post:
      summary: Send one message
      parameters: [{ $ref: "#/components/parameters/Idempotency" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendRequest" }
      responses:
        "202":
          description: Accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SendResult" }
        "402": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "413": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
    get:
      summary: List messages
      parameters:
        - { name: status, in: query, schema: { type: string } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: recipient, in: query, description: An address or its SHA-256., schema: { type: string } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: until, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200":
          description: A page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Message" } }
                  next_cursor: { type: [string, "null"] }
  /v1/messages/batch:
    post:
      summary: Up to 100 messages on the batch lane, spread over the window
      parameters: [{ $ref: "#/components/parameters/Idempotency" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages]
              properties:
                messages: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/SendRequest" } }
                window_minutes: { type: integer, minimum: 30, maximum: 1440 }
      responses:
        "207":
          description: Each message's own outcome, with http_status.
  /v1/messages/{id}:
    get:
      summary: A message with its recipients and event timeline
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses:
        "200":
          description: The message.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/messages/{id}/preview:
    get:
      summary: The body, while it is kept
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses:
        "200": { description: "{ subject, html, text }" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/messages/{id}/cancel:
    post:
      summary: Cancel while queued or scheduled; the credit comes back
      parameters: [{ $ref: "#/components/parameters/Id" }]
      responses:
        "200": { description: Cancelled. }
        "409": { $ref: "#/components/responses/Error" }
  /v1/attachments:
    post:
      summary: Upload a file (10 MB, kept 24 hours)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "201": { description: "{ id, filename, content_type, size_bytes, expires_at }" }
        "413": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/domains:
    get: { summary: Your sending domains, responses: { "200": { description: "{ data: [...] }" } } }
    post:
      summary: Add a domain; the answer lists its DNS records
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name], properties: { name: { type: string } } }
      responses:
        "201": { description: The domain. }
        "409": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/domains/{id}:
    get: { summary: A domain, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: The domain. } } }
    delete: { summary: Release it; the name cools for 30 days, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "204": { description: Released. } } }
  /v1/domains/{id}/dns:
    get: { summary: The DNS records to publish, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: "{ records: [...] }" } } }
  /v1/domains/{id}/verify:
    post: { summary: Check DNS and SES now, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: The domain. } } }
  /v1/domains/{id}/cloudflare:
    post:
      summary: Write the records with your Cloudflare token (zone DNS edit); the token is not stored
      parameters: [{ $ref: "#/components/parameters/Id" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "200": { description: "{ records, domain }" }
        "422": { $ref: "#/components/responses/Error" }
  /v1/domains/{id}/dmarc:
    get: { summary: DMARC reports received in the last 30 days, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: The summary. } } }
  /v1/suppressions:
    get: { summary: Addresses you will not reach, responses: { "200": { description: "{ data: [...] }" } } }
    post:
      summary: Add one by hand
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [email], properties: { email: { type: string } } }
      responses: { "201": { description: Added. } }
  /v1/suppressions/{email}:
    delete:
      summary: Remove one you added by hand; bounces and complaints stay
      parameters: [{ name: email, in: path, required: true, schema: { type: string } }]
      responses:
        "204": { description: Removed. }
        "403": { $ref: "#/components/responses/Error" }
  /v1/webhooks:
    get: { summary: Your webhooks, responses: { "200": { description: "{ data: [...] }" } } }
    post:
      summary: Add one; the signing secret is in this answer only
      description: |
        Each delivery carries `X-LT-Timestamp` and `X-LT-Signature: v1=HMAC-SHA256(secret, timestamp "." sha256hex(body))`.
        Failures retry after 1 minute, 5, 30, 2 hours and 12 hours. An endpoint failing for 24 hours is switched off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, description: Public https. }
                event_types:
                  type: array
                  items: { type: string, enum: [message.queued, message.sending, message.sent, message.delivered, message.bounced, message.complained, message.delayed, message.failed, message.cancelled, message.opened, message.clicked] }
      responses: { "201": { description: "{ id, url, event_types, enabled, secret }" } }
  /v1/webhooks/{id}:
    patch: { summary: Enable, disable or change events, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: The webhook. } } }
    delete: { summary: Remove it, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "204": { description: Removed. } } }
  /v1/webhooks/{id}/deliveries:
    get: { summary: Delivery log, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: "{ data: [...] }" } } }
  /v1/webhooks/deliveries/{id}/replay:
    post: { summary: Send a delivery again, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "202": { description: Queued. } } }
  /v1/account/usage:
    get: { summary: "Credits, per-lane counts and timings, reputation", responses: { "200": { description: The usage. } } }
  /v1/account/settings:
    patch: { summary: "Name, body retention (0-168 hours), whether plain addresses are stored", responses: { "200": { description: The settings. } } }
  /v1/account/forget:
    post: { summary: Erase an address's plain form; its hash stays so suppression keeps working, responses: { "200": { description: "{ recipients_cleared }" } } }
  /v1/account/mcp-connections:
    get: { summary: Who holds access through MCP, responses: { "200": { description: "{ data: [...] }" } } }
  /v1/account/mcp-connections/{id}:
    delete: { summary: End one at once, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "204": { description: Ended. } } }
  /v1/account/recipients:
    get: { summary: Addresses verified for sandbox sending, responses: { "200": { description: "{ data: [...] }" } } }
    post: { summary: Ask an address to confirm, responses: { "201": { description: "{ email, status }" } } }
  /v1/api-keys:
    get: { summary: Your keys; never their secrets, responses: { "200": { description: "{ data: [...] }" } } }
    post: { summary: Make a key; the full key is in this answer only, responses: { "201": { description: "{ id, key, prefix, ... }" } } }
  /v1/api-keys/{id}/rotate:
    post: { summary: A new key now; the old one works for 24 more hours, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "200": { description: The new key. } } }
  /v1/api-keys/{id}:
    delete: { summary: Revoke at once, parameters: [{ $ref: "#/components/parameters/Id" }], responses: { "204": { description: Revoked. } } }
